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:
Artemy
2025-07-19 17:51:06 +07:00
parent ab643c16a4
commit ff873ae179
25 changed files with 485 additions and 124 deletions
@@ -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"));
}
}