From ce630fbe0abd4e2ff86f5651c92cf34ec8e3e602 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:54:58 +0700 Subject: [PATCH] Add sessionId to JWT and update session handling The JwtService now includes sessionId in access tokens, and all usages have been updated to pass sessionId when generating tokens. UserSession now supports public encryption and signing keys. Tests and session management logic have been updated to reflect these changes, ensuring session-specific claims in JWTs and improved session tracking. --- .../Authentication/SessionKeysController.cs | 17 +++++++ .../Authentication/JwtServiceTests.cs | 47 +++++++++++++++---- .../UserSessions/UserSessionOpenerTests.cs | 17 ++++--- .../UserSessions/UserSessionRefresherTests.cs | 2 +- .../Interfaces/Authentication/IJwtService.cs | 2 +- .../Services/Authentication/JwtService.cs | 3 +- .../UserSessions/UserSessionOpener.cs | 32 +++++++------ .../UserSessions/UserSessionRefresher.cs | 2 +- Govor.Core/Models/Users/UserSession.cs | 2 + 9 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 Govor.API/Controllers/Authentication/SessionKeysController.cs 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;