mirror of
https://github.com/Govor-team/Govor.git
synced 2026-07-21 19:54:55 +00:00
Implement refresh token flow and refactor session handling
Added refresh token endpoint and controller, introduced IUserSessionRefresher and UserSessionRefresher for token renewal, and updated session handling to return both access and refresh tokens. Refactored AuthController, tests, and related interfaces to support new token flow. Fixed JwtAccessOption property typo, updated configuration, and extended UserSessionsRepository to support lookup by refresh token.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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<ILogger<UserSessionOpener>>();
|
||||
_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<UserSession> { session });
|
||||
|
||||
_repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List<UserSession> { session });
|
||||
|
||||
// Act
|
||||
var result = await _service.OpenSessionAsync(_user, DeviceInfo);
|
||||
|
||||
// Asser
|
||||
Assert.That(result, Is.EqualTo("valid-token"));
|
||||
_repositoryMock.Verify(r => r.UpdateAsync(It.IsAny<UserSession>()), Times.Never);
|
||||
// Assert
|
||||
Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken));
|
||||
Assert.That(result.accessToken, Is.EqualTo(NewAccessToken));
|
||||
_repositoryMock.Verify(r => r.UpdateAsync(It.Is<UserSession>(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<UserSession> { session });
|
||||
|
||||
_repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List<UserSession> { session });
|
||||
|
||||
// Act
|
||||
var result = await _service.OpenSessionAsync(_user, DeviceInfo);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(GeneratedToken));
|
||||
_repositoryMock.Verify(r => r.UpdateAsync(It.Is<UserSession>(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<UserSession>(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<UserSession>());
|
||||
_repositoryMock.Setup(r => r.GetByUserIdAsync(_user.Id)).ReturnsAsync(new List<UserSession>());
|
||||
|
||||
// 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<UserSession>(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<Guid>(_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<UserSession>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IUserSessionsRepository> _sessionsRepoMock;
|
||||
private Mock<IUsersRepository> _usersRepoMock;
|
||||
private Mock<IJwtService> _jwtServiceMock;
|
||||
private Mock<ILogger<UserSessionRefresher>> _loggerMock;
|
||||
private Mock<IOptions<JwtRefreshOption>> _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<IUserSessionsRepository>();
|
||||
_usersRepoMock = new Mock<IUsersRepository>();
|
||||
_jwtServiceMock = new Mock<IJwtService>();
|
||||
_loggerMock = new Mock<ILogger<UserSessionRefresher>>();
|
||||
_optionsMock = new Mock<IOptions<JwtRefreshOption>>();
|
||||
|
||||
_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<UserSession>(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<UnauthorizedAccessException>(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<UnauthorizedAccessException>(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<string>("token", OldRefreshToken));
|
||||
// Act & Assert
|
||||
var ex = Assert.ThrowsAsync<UnauthorizedAccessException>(async () =>
|
||||
await _refresher.RefreshTokenAsync(OldRefreshToken));
|
||||
|
||||
Assert.That(ex.Message, Contains.Substring("Invalid refresh token"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user