diff --git a/Govor.API/Controllers/Authentication/SessionKeysController.cs b/Govor.API/Controllers/Authentication/SessionKeysController.cs new file mode 100644 index 0000000..b001c70 --- /dev/null +++ b/Govor.API/Controllers/Authentication/SessionKeysController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Authentication; + +[RequireHttps] +[ApiController] +[Route("api/session")] +[Authorize(Roles = "Admin, User")] +public class SessionKeysController : Controller +{ + // GET + public IActionResult Index() + { + return View(); + } +} \ No newline at end of file diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index 62953df..777e876 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -56,22 +56,51 @@ public class JwtServiceTests } [Test] - public async Task GenerateJwtToken_ShouldReturnValidJwtString() + public async Task GenerateJwtToken_ShouldReturnValidJwtString_WithCorrectClaims() { // Arrange var user = _fixture.Create(); + var sessionId = Guid.NewGuid(); var expectedRole = "User"; - _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); - // Act - var tokenString = await _jwtService.GenerateAccessTokenAsync(user); + _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).ReturnsAsync(expectedRole); + + // Act + var tokenString = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + + // Assert + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(tokenString); + + var claims = jwt.Claims.ToDictionary(c => c.Type, c => c.Value); + + Assert.That(claims["userId"], Is.EqualTo(user.Id.ToString())); + Assert.That(claims["sid"], Is.EqualTo(sessionId.ToString())); + Assert.That(claims[ClaimTypes.Role], Is.EqualTo(expectedRole)); + Assert.That(jwt.ValidTo, Is.GreaterThan(DateTime.UtcNow)); + } + + [Test] + public async Task GenerateAccessTokenAsync_ShouldIncludeSessionIdAndRole() + { + // Arrange + var user = new User { Id = Guid.NewGuid(), Username = "TestUser" }; + var sessionId = Guid.NewGuid(); + var role = "Admin"; + _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).ReturnsAsync(role); + + // Act + var token = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); // Assert - Assert.That(tokenString, Is.Not.Null.And.Not.Empty); - - // Attempt to parse the token to ensure it's a JWT - var handler = new JwtSecurityTokenHandler(); - Assert.DoesNotThrow(() => handler.ReadJwtToken(tokenString)); + var sidClaim = jwt.Claims.FirstOrDefault(c => c.Type == "sid"); + var roleClaim = jwt.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role); + + Assert.That(sidClaim?.Value, Is.EqualTo(sessionId.ToString())); + Assert.That(roleClaim?.Value, Is.EqualTo(role)); } + [Test] public async Task GenerateRefreshTokenAsync_ReturnsValidRefreshToken() diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index 278c4f5..b08cc73 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -1,5 +1,4 @@ using Govor.Application.Services.UserSessions; -using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; using Microsoft.Extensions.Logging; @@ -19,6 +18,7 @@ public class UserSessionOpenerTests private IOptions _options; private UserSessionOpener _service; private User _user; + private Guid _sessionId; private const string DeviceInfo = "Chrome on Windows"; private const string GeneratedToken = "new-refresh-token"; private const string NewAccessToken = "new-access-token"; @@ -30,8 +30,9 @@ public class UserSessionOpenerTests _jwtServiceMock = new Mock(); _loggerMock = new Mock>(); _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); - - // Инициализируем пользователя ДО моков, чтобы не было null в Setup + + _sessionId = Guid.NewGuid(); + _user = new User { Id = Guid.NewGuid(), @@ -44,7 +45,7 @@ public class UserSessionOpenerTests }; _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(GeneratedToken); - _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, _sessionId)).ReturnsAsync(NewAccessToken); _service = new UserSessionOpener( _repositoryMock.Object, @@ -60,6 +61,7 @@ public class UserSessionOpenerTests // Arrange var session = new UserSession { + Id = _sessionId, UserId = _user.Id, DeviceInfo = DeviceInfo, RefreshToken = "valid-token", @@ -87,6 +89,7 @@ public class UserSessionOpenerTests // Arrange var session = new UserSession { + Id = _sessionId, UserId = _user.Id, DeviceInfo = DeviceInfo, RefreshToken = "old-token", @@ -113,7 +116,8 @@ public class UserSessionOpenerTests { // Arrange _repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List()); - + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, It.IsAny())).ReturnsAsync(NewAccessToken); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); @@ -134,7 +138,8 @@ public class UserSessionOpenerTests _repositoryMock .Setup(r => r.GetByUserIdAsync(_user.Id)) .ThrowsAsync(new Govor.Data.Repositories.Exceptions.NotFoundByKeyException(_user.Id, "userId")); - + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user, It.IsAny())).ReturnsAsync(NewAccessToken); + // Act var result = await _service.OpenSessionAsync(_user, DeviceInfo); diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs index 0577a5c..38fcacc 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs @@ -73,7 +73,7 @@ public class UserSessionRefresherTests // 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.GenerateAccessTokenAsync(_user, _session.Id)).ReturnsAsync(NewAccessToken); _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(NewRefreshToken); // Act diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 18c7477..d5d54ad 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -5,7 +5,7 @@ namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { - Task GenerateAccessTokenAsync(User user); + Task GenerateAccessTokenAsync(User user, Guid sessionId); Task GenerateRefreshTokenAsync(User user); ClaimsPrincipal GetPrincipalFromExpiredToken(string token); } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 12da414..764ded3 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -21,11 +21,12 @@ public class JwtService : IJwtService _invitesService = invitesService; } - public async Task GenerateAccessTokenAsync(User user) + public async Task GenerateAccessTokenAsync(User user, Guid sessionId) { var claims = new[] { new Claim("userId", user.Id.ToString()), + new Claim("sid", sessionId.ToString()), new Claim(ClaimTypes.Role, await _invitesService.GetRoleAsync(user), ClaimValueTypes.String) }; diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs index 2d3d2be..0838eff 100644 --- a/Govor.Application/Services/UserSessions/UserSessionOpener.cs +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -36,15 +36,13 @@ namespace Govor.Application.Services.UserSessions { 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) { - // Всегда обновляем токен и дату + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + session.RefreshToken = newRefreshToken; session.ExpiresAt = newExpiresAt; session.CreatedAt = DateTime.UtcNow; @@ -52,7 +50,7 @@ namespace Govor.Application.Services.UserSessions await _repository.UpdateAsync(session); _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); - + return new RefreshResult(session.RefreshToken, accessToken); } @@ -65,25 +63,29 @@ namespace Govor.Application.Services.UserSessions async Task OpenNewSession() { - var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); - var accessToken = await _jwtService.GenerateAccessTokenAsync(user); - + var sessionId = Guid.NewGuid(); + + var accessToken = await _jwtService.GenerateAccessTokenAsync(user, sessionId); + var refreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var newSession = new UserSession { + Id = sessionId, UserId = user.Id, DeviceInfo = deviceInfo, - RefreshToken = newRefreshToken, + RefreshToken = refreshToken, 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}'"); - return new RefreshResult(newRefreshToken, accessToken); + _logger.LogInformation($"Created new session {sessionId} for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(refreshToken, accessToken); } + } } } diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs index b6a968f..701bd13 100644 --- a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -48,7 +48,7 @@ public class UserSessionRefresher : IUserSessionRefresher var user = await _usersRepository.FindByIdAsync(session.UserId); // New tokens - var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user, session.Id); var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); // Opening new session diff --git a/Govor.Core/Models/Users/UserSession.cs b/Govor.Core/Models/Users/UserSession.cs index 62f8384..b9cf558 100644 --- a/Govor.Core/Models/Users/UserSession.cs +++ b/Govor.Core/Models/Users/UserSession.cs @@ -6,6 +6,8 @@ public class UserSession public Guid UserId { get; set; } public string RefreshToken { get; set; } = string.Empty; public string DeviceInfo { get; set; } = string.Empty; // "Chrome on Windows" + public string PublicEncryptionKey { get; set; } + public string PublicSigningKey { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime ExpiresAt { get; set; } public bool IsRevoked { get; set; } = false;