diff --git a/src/FiscalOS.API/Login/Response.cs b/src/FiscalOS.API/Login/Response.cs index d4f0523..93965e9 100644 --- a/src/FiscalOS.API/Login/Response.cs +++ b/src/FiscalOS.API/Login/Response.cs @@ -1,3 +1,16 @@ namespace FiscalOS.API.Login; -internal sealed record Response(string AccessToken); \ No newline at end of file +internal sealed record Response +{ + public string AccessToken { get; init; } + + private Response(string accessToken) + { + AccessToken = accessToken; + } + + public static Response From(string accessToken) + { + return new(accessToken); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Core/Authentication/IPasswordHasher.cs b/src/FiscalOS.Core/Authentication/IPasswordHasher.cs new file mode 100644 index 0000000..9433c31 --- /dev/null +++ b/src/FiscalOS.Core/Authentication/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace FiscalOS.Core.Authentication; + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string providedPassword, string hashedPassword); +} \ No newline at end of file diff --git a/src/FiscalOS.Core/Authentication/ITokenGenerator.cs b/src/FiscalOS.Core/Authentication/ITokenGenerator.cs new file mode 100644 index 0000000..f4e6cdb --- /dev/null +++ b/src/FiscalOS.Core/Authentication/ITokenGenerator.cs @@ -0,0 +1,7 @@ +namespace FiscalOS.Core.Authentication; + +public interface ITokenGenerator +{ + string GenerateAccessToken(User user); + RefreshToken GenerateRefreshToken(User user); +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Data/AppDbContext.cs b/src/FiscalOS.Infra/Data/AppDbContext.cs index bd0c681..3975671 100644 --- a/src/FiscalOS.Infra/Data/AppDbContext.cs +++ b/src/FiscalOS.Infra/Data/AppDbContext.cs @@ -23,7 +23,7 @@ public sealed class AppDbContext(IOptions ctxOptions) : DbC var connectionString = $"{DataSourceKey}{dbPath}"; optionsBuilder.UseSqlite(connectionString) - .AddInterceptors(new TimestampInterceptor()); + .AddInterceptors(TimestampInterceptor.New()); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/src/FiscalOS.Infra/Data/TimestampInterceptor.cs b/src/FiscalOS.Infra/Data/TimestampInterceptor.cs index 2d21c9c..dda4871 100644 --- a/src/FiscalOS.Infra/Data/TimestampInterceptor.cs +++ b/src/FiscalOS.Infra/Data/TimestampInterceptor.cs @@ -4,6 +4,15 @@ namespace FiscalOS.Infra.Data; internal sealed class TimestampInterceptor : SaveChangesInterceptor { + private TimestampInterceptor() + { + } + + public static TimestampInterceptor New() + { + return new(); + } + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { UpdateEntities(eventData.Context); diff --git a/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs b/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs new file mode 100644 index 0000000..74ae952 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Assertions/JwtTokenAssertions.cs @@ -0,0 +1,45 @@ +namespace FiscalOS.Infra.Tests.Assertions; + +internal static class JwtTokenAssertionExtensions +{ + public static AndConstraint HaveClaim( + this StringAssertions assertions, + string claimType + ) + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(assertions.Subject); + + var claim = jwtToken.Claims.FirstOrDefault(c => c.Type == claimType); + + assertions.CurrentAssertionChain + .ForCondition(claim is not null) + .FailWith($"Expected JWT token to have claim '{claimType}', but it was not found."); + + return new AndConstraint(assertions.Subject); + } + + public static AndConstraint HaveClaimWithValue( + this StringAssertions assertions, + string claimType, + string expectedValue + ) + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(assertions.Subject); + + var claim = jwtToken.Claims.FirstOrDefault(c => c.Type == claimType); + + assertions.CurrentAssertionChain + .ForCondition(claim is not null) + .FailWith($"Expected JWT token to have claim '{claimType}', but it was not found."); + + assertions.CurrentAssertionChain + .ForCondition(claim!.Value == expectedValue) + .FailWith( + $"Expected JWT token claim '{claimType}' to have value '{expectedValue}', but found '{claim.Value}'." + ); + + return new AndConstraint(assertions.Subject); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs b/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs new file mode 100644 index 0000000..1f23d9a --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Security.Cryptography; + +namespace FiscalOS.Infra.Tests.Unit; + +public class TokenGeneratorTests +{ + private readonly Mock> _mockJwtOptions = new(); + private readonly Mock _mockTimeProvider = new(); + private readonly TokenGenerator _sut; + + public TokenGeneratorTests() + { + _sut = TokenGenerator.From( + _mockTimeProvider.Object, + _mockJwtOptions.Object + ); + } + + [Fact] + public void GenerateAccessToken_WhenCalled_ItShouldReturnValidJwtToken() + { + var user = User.From("testuser", "hashedpassword"); + + var madeUp256BitSecret = RandomNumberGenerator.GetBytes(32); + + var jwtOptions = new JwtOptions + { + Secret = Convert.ToBase64String(madeUp256BitSecret), + Issuer = "TestIssuer", + Audience = "TestAudience", + ExpiryInMinutes = 60, + }; + + _mockJwtOptions + .SetupGet(static x => x.Value) + .Returns(jwtOptions); + + var now = DateTimeOffset.UtcNow; + + _mockTimeProvider + .Setup(static x => x.GetUtcNow()) + .Returns(now); + + var token = _sut.GenerateAccessToken(user); + + token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Sub, user.Id.ToString()); + token.Should().HaveClaim(JwtRegisteredClaimNames.Jti); + token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Iss, jwtOptions.Issuer); + token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Aud, jwtOptions.Audience); + token.Should().HaveClaimWithValue( + JwtRegisteredClaimNames.Exp, + new DateTimeOffset( + now.AddMinutes(jwtOptions.ExpiryInMinutes).UtcDateTime + ) + .ToUnixTimeSeconds() + .ToString(CultureInfo.InvariantCulture) + ); + } + + [Fact] + public void GenerateRefreshToken_WhenCalled_ItShouldReturnRefreshTokenWithCorrectProperties() + { + var user = User.From("testuser", "hashedpassword"); + + var now = DateTimeOffset.UtcNow; + + _mockTimeProvider + .Setup(static x => x.GetUtcNow()) + .Returns(now); + + var refreshToken = _sut.GenerateRefreshToken(user); + + refreshToken.UserId.Should().Be(user.Id); + refreshToken.ExpiresAt.Should().Be(now.AddHours(12)); + refreshToken.Token.Should().NotBeNullOrEmpty(); + } +} \ No newline at end of file