feat(core): add password hashing and token generation interfaces
This commit is contained in:
@@ -1,3 +1,16 @@
|
|||||||
namespace FiscalOS.API.Login;
|
namespace FiscalOS.API.Login;
|
||||||
|
|
||||||
internal sealed record Response(string AccessToken);
|
internal sealed record Response
|
||||||
|
{
|
||||||
|
public string AccessToken { get; init; }
|
||||||
|
|
||||||
|
private Response(string accessToken)
|
||||||
|
{
|
||||||
|
AccessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Response From(string accessToken)
|
||||||
|
{
|
||||||
|
return new(accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FiscalOS.Core.Authentication;
|
||||||
|
|
||||||
|
public interface IPasswordHasher
|
||||||
|
{
|
||||||
|
string Hash(string password);
|
||||||
|
bool Verify(string providedPassword, string hashedPassword);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FiscalOS.Core.Authentication;
|
||||||
|
|
||||||
|
public interface ITokenGenerator
|
||||||
|
{
|
||||||
|
string GenerateAccessToken(User user);
|
||||||
|
RefreshToken GenerateRefreshToken(User user);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
|||||||
var connectionString = $"{DataSourceKey}{dbPath}";
|
var connectionString = $"{DataSourceKey}{dbPath}";
|
||||||
|
|
||||||
optionsBuilder.UseSqlite(connectionString)
|
optionsBuilder.UseSqlite(connectionString)
|
||||||
.AddInterceptors(new TimestampInterceptor());
|
.AddInterceptors(TimestampInterceptor.New());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ namespace FiscalOS.Infra.Data;
|
|||||||
|
|
||||||
internal sealed class TimestampInterceptor : SaveChangesInterceptor
|
internal sealed class TimestampInterceptor : SaveChangesInterceptor
|
||||||
{
|
{
|
||||||
|
private TimestampInterceptor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TimestampInterceptor New()
|
||||||
|
{
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
||||||
{
|
{
|
||||||
UpdateEntities(eventData.Context);
|
UpdateEntities(eventData.Context);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace FiscalOS.Infra.Tests.Assertions;
|
||||||
|
|
||||||
|
internal static class JwtTokenAssertionExtensions
|
||||||
|
{
|
||||||
|
public static AndConstraint<string> 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<string>(assertions.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AndConstraint<string> 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<string>(assertions.Subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Tests.Unit;
|
||||||
|
|
||||||
|
public class TokenGeneratorTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IOptions<JwtOptions>> _mockJwtOptions = new();
|
||||||
|
private readonly Mock<TimeProvider> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user