Compare commits

...
Author SHA1 Message Date
Stevan Freeborn eef410d71b chore: run dotnet format 2026-02-02 15:46:12 -06:00
Stevan Freeborn fb7750328b feat(infra): add indexes with uniqueness 2026-02-02 15:45:44 -06:00
Stevan Freeborn 35557a8aa7 feat(admincli): add command for creating a user 2026-02-02 15:41:47 -06:00
Stevan Freeborn 5a5efbefd7 tests(api): add integration test for happy path when logging in 2026-02-02 13:33:23 -06:00
Stevan Freeborn dcc5b2e0f0 feat(core): add password hashing and token generation interfaces 2026-02-02 13:19:36 -06:00
Stevan Freeborn 7d5fd4149c test: update infra tests for new authentication namespaces 2026-02-02 13:18:41 -06:00
Stevan Freeborn abc6a7655e test: add moq and code coverage support to test suite 2026-02-02 13:18:28 -06:00
Stevan Freeborn 178f03459b feat(api): integrate abstracted token generation and update login endpoint 2026-02-02 13:18:15 -06:00
Stevan Freeborn b3bfa64b96 feat(infra): move authentication implementations and add ITokenGenerator 2026-02-02 13:17:59 -06:00
Stevan Freeborn 098aa23c62 refactor(core): encapsulate user and refresh token creation with factory methods 2026-02-02 13:17:43 -06:00
Stevan Freeborn 815b6aa57c chore: add todo with psuedo code 2026-02-02 07:00:37 -06:00
Stevan Freeborn 3a1a7138c5 feat(infra): update refresh token model 2026-02-02 07:00:28 -06:00
Stevan Freeborn ee6abc4559 chore: add example jwt options in example settings file 2026-02-02 06:54:39 -06:00
Stevan Freeborn b535c0a121 feat: implement token generation and issuing on successful login 2026-02-02 06:32:14 -06:00
Stevan Freeborn 822da7368c tests: refactor test suite to match new project structure 2026-02-02 04:49:00 -06:00
Stevan Freeborn 7c6e43d62d feat(api): integrate infra and implement password verification on login 2026-02-02 04:48:42 -06:00
Stevan Freeborn 48913fc434 feat: add timestamp interceptor and infrastructure service registration 2026-02-02 04:48:10 -06:00
Stevan Freeborn c01d0b5daa refactor(infra): migrate data access and identity impelementations to infrastructure 2026-02-02 04:47:32 -06:00
Stevan Freeborn 7a7ab90155 refactor(core): move identity models from API to Core 2026-02-02 04:45:56 -06:00
Stevan Freeborn 81e5b9ed6a feat(core): add base entity and password hashing abstractions 2026-02-02 04:45:17 -06:00
Stevan Freeborn b5ea9a514f feat: initialize core, infra, and admin cli projects 2026-02-02 04:44:13 -06:00
Stevan Freeborn c8c45f6a83 feat: implement password hasher 2026-02-01 11:32:14 -06:00
Stevan Freeborn ce45244813 chore: move editorconfig to correct location 2026-02-01 11:31:58 -06:00
Stevan Freeborn e479ba4c8a tests: fix integration test setup 2026-02-01 11:31:46 -06:00
Stevan Freeborn bf7b1b3fc8 fix: remove fake auth check 2026-02-01 11:31:25 -06:00
Stevan Freeborn 872eb3e95d feat: wip on testing login flows 2026-02-01 08:07:23 -06:00
Stevan Freeborn 725392227e feat: setup db context with initial models 2026-02-01 06:13:08 -06:00
Stevan Freeborn 093ee2c357 feat: add simple check for username and password 2026-02-01 06:12:49 -06:00
Stevan Freeborn a1d22b0ec2 chore: add example appsettings file 2026-02-01 06:12:25 -06:00
Stevan Freeborn 2b91e3b791 refactor: use ivalidatableobject api 2026-02-01 06:12:09 -06:00
Stevan Freeborn 8e40b15cb2 chore: update editorconfig settings 2026-02-01 06:11:31 -06:00
Stevan Freeborn 8012ecaa73 feat: wip on login endpoint
- added basic REPR pattern
- added basic vert architecture
- implemented basica validation for single test case
2026-02-01 04:27:06 -06:00
63 changed files with 2081 additions and 6 deletions
+3 -1
View File
@@ -59,7 +59,7 @@ dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion dotnet_style_prefer_conditional_expression_over_return = false:silent
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion
@@ -81,7 +81,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none
# analyzer settings # analyzer settings
dotnet_diagnostic.IDE0058.severity = none dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0100.severity = none
dotnet_diagnostic.CA1515.severity = none dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1848.severity = none
# var preferences # var preferences
csharp_style_var_elsewhere = true:suggestion csharp_style_var_elsewhere = true:suggestion
+60
View File
@@ -11,6 +11,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.AdminCLI", "src\FiscalOS.AdminCLI\FiscalOS.AdminCLI.csproj", "{C46818A0-1A92-4E4F-8390-48CBA9971745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core", "src\FiscalOS.Core\FiscalOS.Core.csproj", "{612496CD-1F25-4804-8C52-49E62D01FFDC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\FiscalOS.Infra\FiscalOS.Infra.csproj", "{DAB42870-02A2-4ECB-B07F-6815C52D2435}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -45,6 +53,54 @@ Global
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU {85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU {85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU {85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -52,5 +108,9 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C46818A0-1A92-4E4F-8390-48CBA9971745} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
+9 -3
View File
@@ -1,10 +1,16 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA2007.severity = none
+9
View File
@@ -2,6 +2,15 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+54
View File
@@ -0,0 +1,54 @@
namespace FiscalOS.API.Login;
internal static class Endpoint
{
private const string Route = "/login";
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
{
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] LoginRequest loginRequest,
[FromServices] AppDbContext appDbContext,
[FromServices] IPasswordHasher passwordHasher,
[FromServices] ITokenGenerator tokenService
)
{
var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Username == loginRequest.Username);
if (user is null)
{
return Results.Unauthorized();
}
var isCorrectPassword = passwordHasher.Verify(loginRequest.Password, user.HashedPassword);
if (isCorrectPassword is false)
{
return Results.Unauthorized();
}
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
await appDbContext.RefreshTokens.AddAsync(refreshToken);
await appDbContext.SaveChangesAsync();
httpContext.Response.Cookies.Append(
"fiscalos_refresh_cookie",
refreshToken.Token,
new CookieOptions
{
HttpOnly = true,
Expires = refreshToken.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
return Results.Ok(Response.From(accessToken));
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace FiscalOS.API.Login;
public record LoginRequest : IValidatableObject
{
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Username))
{
yield return new("The Username field is required.", [nameof(Username)]);
}
if (string.IsNullOrWhiteSpace(Password))
{
yield return new("The Password field is required.", [nameof(Password)]);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace FiscalOS.API.Login;
internal sealed record Response
{
public string AccessToken { get; init; }
[JsonConstructor]
private Response()
{
AccessToken = string.Empty;
}
private Response(string accessToken)
{
AccessToken = accessToken;
}
public static Response From(string accessToken)
{
return new(accessToken);
}
}
+5
View File
@@ -1,7 +1,10 @@
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddInfrastructure();
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -11,4 +14,6 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.MapLoginEndpoint();
app.Run(); app.Run();
+5
View File
@@ -1,5 +1,10 @@
global using System.ComponentModel.DataAnnotations; global using System.ComponentModel.DataAnnotations;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Login; global using FiscalOS.API.Login;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AppDbContextOptions": {
"DatabaseFilePath": "DatabaseFilePath"
},
"JwtOptions": {
"Audience": "Audience",
"Issuer": "Issuer",
"Secret": "Secret",
"ExpiryInMinutes": "ExpiryInMinutes"
}
}
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1303.severity = none
dotnet_diagnostic.CA2007.severity = none
+52
View File
@@ -0,0 +1,52 @@
namespace FiscalOS.AdminCLI;
internal sealed class App(
IAnsiConsole console,
IPasswordHasher passwordHasher,
IServiceScopeFactory serviceScopeFactory
) : IHostedService
{
private readonly IAnsiConsole _console = console;
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var selectionPrompt = new SelectionPrompt<string>()
.Title("What do you want to do?")
.AddChoices(Commands.All);
var command = await _console.PromptAsync(selectionPrompt, cancellationToken);
switch (command)
{
case Commands.CreateUser:
var username = await _console.AskAsync<string>("Enter the [green]username[/]:", cancellationToken);
var password = _console.Prompt(
new TextPrompt<string>("Enter the [green]password[/]:")
.PromptStyle("red")
.Secret()
);
var hashedPassword = passwordHasher.Hash(password);
var user = User.From(username, hashedPassword);
await appDbContext.Users.AddAsync(user, cancellationToken);
await appDbContext.SaveChangesAsync(cancellationToken);
_console.MarkupLine($"User [green]{username}[/] created successfully.");
break;
case Commands.Exit:
default:
break;
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace FiscalOS.AdminCLI;
internal static class Commands
{
public const string CreateUser = "Create User";
public const string Exit = "Exit";
public static readonly string[] All = [CreateUser, Exit];
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
</ItemGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
await Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(
static c => c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
)
.ConfigureLogging(static c => c.ClearProviders())
.ConfigureServices(static (_, services) =>
{
services.AddSingleton(AnsiConsole.Console);
services.AddInfrastructure();
services.AddHostedService<App>();
})
.Build()
.StartAsync();
+12
View File
@@ -0,0 +1,12 @@
global using FiscalOS.AdminCLI;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using Spectre.Console;
@@ -0,0 +1,5 @@
{
"AppDbContextOptions": {
"DatabaseFilePath": "DatabaseFilePath"
}
}
@@ -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);
}
+18
View File
@@ -0,0 +1,18 @@
namespace FiscalOS.Core.Data;
public abstract class Entity
{
public Guid Id { get; init; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public void SetCreatedAt(DateTimeOffset createdAt)
{
CreatedAt = createdAt;
}
public void SetUpdatedAt(DateTimeOffset updatedAt)
{
UpdatedAt = updatedAt;
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,23 @@
namespace FiscalOS.Core.Identity;
public sealed class RefreshToken : Entity
{
public string Token { get; init; } = string.Empty;
public DateTimeOffset ExpiresAt { get; init; }
public Guid UserId { get; init; }
public User User { get; init; } = User.New();
private RefreshToken()
{
}
public static RefreshToken From(Guid userId, string token, DateTimeOffset expiresAt)
{
return new()
{
UserId = userId,
Token = token,
ExpiresAt = expiresAt,
};
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace FiscalOS.Core.Identity;
public sealed class User : Entity
{
public string Username { get; init; } = string.Empty;
public string HashedPassword { get; init; } = string.Empty;
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
private User()
{
}
public static User New()
{
return new();
}
public static User From(string username, string hashedPassword)
{
return new()
{
Username = username,
HashedPassword = hashedPassword,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
+3
View File
@@ -0,0 +1,3 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
@@ -0,0 +1,25 @@
namespace FiscalOS.Infra.Authentication;
public sealed record JwtOptions
{
public string Issuer { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public int ExpiryInMinutes { get; init; } = 5;
}
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
{
private const string SectionName = nameof(JwtOptions);
private readonly IConfiguration _configuration;
public JwtOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(JwtOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -0,0 +1,63 @@
namespace FiscalOS.Infra.Authentication;
public sealed class PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 100_000;
private static readonly HashAlgorithmName HashAlgorithm = HashAlgorithmName.SHA512;
private PasswordHasher()
{
}
public static PasswordHasher New()
{
return new();
}
public static PasswordHasher From(IServiceProvider serviceProvider)
{
return new();
}
public string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
Iterations,
HashAlgorithm,
HashSize
);
var hashBytes = new byte[SaltSize + HashSize];
Array.Copy(salt, 0, hashBytes, 0, SaltSize);
Array.Copy(hash, 0, hashBytes, SaltSize, HashSize);
return Convert.ToBase64String(hashBytes);
}
public bool Verify(string providedPassword, string hashedPassword)
{
var hashBytes = Convert.FromBase64String(hashedPassword);
var salt = new byte[SaltSize];
Array.Copy(hashBytes, 0, salt, 0, SaltSize);
var storedHash = new byte[HashSize];
Array.Copy(hashBytes, SaltSize, storedHash, 0, HashSize);
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
providedPassword,
salt,
Iterations,
HashAlgorithm,
HashSize
);
return CryptographicOperations.FixedTimeEquals(storedHash, computedHash);
}
}
@@ -0,0 +1,78 @@
namespace FiscalOS.Infra.Authentication;
public sealed class TokenGenerator : ITokenGenerator
{
private const int RefreshTokenExpiryInHours = 12;
private readonly IOptions<JwtOptions> _jwtOptions;
private readonly TimeProvider _timeProvider;
private TokenGenerator(
TimeProvider timeProvider,
IOptions<JwtOptions> jwtOptions
)
{
_timeProvider = timeProvider;
_jwtOptions = jwtOptions;
}
public static TokenGenerator From(IServiceProvider serviceProvider)
{
var timeProvider = serviceProvider.GetRequiredService<TimeProvider>();
var jwtOptions = serviceProvider.GetRequiredService<IOptions<JwtOptions>>();
return new(timeProvider, jwtOptions);
}
public static TokenGenerator From(
TimeProvider timeProvider,
IOptions<JwtOptions> jwtOptions
)
{
return new(timeProvider, jwtOptions);
}
public string GenerateAccessToken(User user)
{
var tokenHandler = new JwtSecurityTokenHandler();
var secretKeyBytes = Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret);
var issuedAt = _timeProvider.GetUtcNow();
var expiresAt = issuedAt.AddMinutes(_jwtOptions.Value.ExpiryInMinutes);
List<Claim> claims = [
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
];
var descriptor = new SecurityTokenDescriptor()
{
Subject = new(claims),
IssuedAt = issuedAt.UtcDateTime,
Expires = expiresAt.UtcDateTime,
Issuer = _jwtOptions.Value.Issuer,
Audience = _jwtOptions.Value.Audience,
SigningCredentials = new(
new SymmetricSecurityKey(secretKeyBytes),
SecurityAlgorithms.HmacSha256Signature
),
};
var securityToken = tokenHandler.CreateJwtSecurityToken(descriptor);
var jwtToken = tokenHandler.WriteToken(securityToken);
return jwtToken;
}
public RefreshToken GenerateRefreshToken(User user)
{
var expiresAt = _timeProvider
.GetUtcNow()
.AddHours(RefreshTokenExpiryInHours);
return RefreshToken.From(user.Id, GenerateToken(), expiresAt);
}
private static string GenerateToken()
{
var randomBytes = RandomNumberGenerator.GetBytes(32);
var token = Convert.ToBase64String(randomBytes);
return token;
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace FiscalOS.Infra.Data;
public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbContext
{
private const string DataSourceKey = "Data Source=";
private readonly AppDbContextOptions _ctxOptions = ctxOptions.Value;
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath();
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
if (Directory.Exists(dbDirectory) is false)
{
Directory.CreateDirectory(dbDirectory);
}
var connectionString = $"{DataSourceKey}{dbPath}";
optionsBuilder.UseSqlite(connectionString)
.AddInterceptors(TimestampInterceptor.New());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var entityTypes = modelBuilder.Model.GetEntityTypes()
.Where(static e => typeof(Entity).IsAssignableFrom(e.ClrType));
foreach (var entityType in entityTypes)
{
modelBuilder.Entity(entityType.ClrType)
.HasKey(nameof(Entity.Id));
modelBuilder.Entity(entityType.ClrType)
.Property(nameof(Entity.CreatedAt));
modelBuilder.Entity(entityType.ClrType)
.Property(nameof(Entity.UpdatedAt));
}
modelBuilder.Entity<User>(static eb =>
{
eb.HasMany(static u => u.RefreshTokens)
.WithOne(static t => t.User)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.Property(static u => u.HashedPassword);
});
modelBuilder.Entity<RefreshToken>(static eb =>
{
eb.Property(static t => t.Id);
eb.Property(static t => t.ExpiresAt);
eb.Property(static t => t.Token);
eb.HasIndex(static t => t.Token).IsUnique();
});
}
}
@@ -0,0 +1,27 @@
namespace FiscalOS.Infra.Data;
public sealed record AppDbContextOptions
{
public string DatabaseFilePath { get; init; } = string.Empty;
public string GetFullyQualifiedDatabasePath()
{
return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory);
}
}
public sealed record AppDbContextOptionsSetup : IConfigureOptions<AppDbContextOptions>
{
private const string SectionName = nameof(AppDbContextOptions);
private readonly IConfiguration _configuration;
public AppDbContextOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(AppDbContextOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -0,0 +1,25 @@
namespace FiscalOS.Infra.Data;
internal sealed class MigrationService(
IServiceProvider serviceProvider,
ILogger<MigrationService> logger
) : IHostedService
{
private readonly IServiceProvider _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Applying database migrations...");
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Database migrations applied.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,51 @@
using FiscalOS.Core.Data;
namespace FiscalOS.Infra.Data;
internal sealed class TimestampInterceptor : SaveChangesInterceptor
{
private TimestampInterceptor()
{
}
public static TimestampInterceptor New()
{
return new();
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
UpdateEntities(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
UpdateEntities(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateEntities(DbContext? context)
{
if (context is null)
{
return;
}
var entries = context.ChangeTracker
.Entries<Entity>()
.Where(static e => e.State is EntityState.Added or EntityState.Modified);
var utcNow = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.SetCreatedAt(utcNow);
}
entry.Entity.SetUpdatedAt(utcNow);
}
}
}
@@ -0,0 +1,16 @@
namespace FiscalOS.Infra.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
services.ConfigureOptions<JwtOptionsSetup>();
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
services.ConfigureOptions<AppDbContextOptionsSetup>();
services.AddDbContext<AppDbContext>();
services.AddHostedService<MigrationService>();
return services;
}
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260201115839_AddUsersAndRefreshTokens")]
partial class AddUsersAndRefreshTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.API.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddUsersAndRefreshTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,72 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260201125529_AddUsernameToUsers")]
partial class AddUsernameToUsers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.API.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.API.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.API.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddUsernameToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Username",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Guid>(
name: "UserId",
table: "RefreshTokens",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_RefreshTokens_Users_UserId",
table: "RefreshTokens",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RefreshTokens_Users_UserId",
table: "RefreshTokens");
migrationBuilder.DropIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens");
migrationBuilder.DropColumn(
name: "Username",
table: "Users");
migrationBuilder.DropColumn(
name: "UserId",
table: "RefreshTokens");
}
}
}
@@ -0,0 +1,88 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260202010259_AddTimestamps")]
partial class AddTimestamps
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddTimestamps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<string>(
name: "HashedPassword",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "UpdatedAt",
table: "Users",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "RefreshTokens",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<DateTimeOffset>(
name: "UpdatedAt",
table: "RefreshTokens",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Users");
migrationBuilder.DropColumn(
name: "HashedPassword",
table: "Users");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "Users");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "RefreshTokens");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "RefreshTokens");
}
}
}
@@ -0,0 +1,95 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260202125635_AddPropsToRefreshToken")]
partial class AddPropsToRefreshToken
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddPropsToRefreshToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ExpiresAt",
table: "RefreshTokens",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<string>(
name: "Token",
table: "RefreshTokens",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExpiresAt",
table: "RefreshTokens");
migrationBuilder.DropColumn(
name: "Token",
table: "RefreshTokens");
}
}
}
@@ -0,0 +1,101 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260202214513_AddIndexesWithUniquenessToModels")]
partial class AddIndexesWithUniquenessToModels
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddIndexesWithUniquenessToModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_Token",
table: "RefreshTokens",
column: "Token",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_Username",
table: "Users");
migrationBuilder.DropIndex(
name: "IX_RefreshTokens_Token",
table: "RefreshTokens");
}
}
}
@@ -0,0 +1,98 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
+19
View File
@@ -0,0 +1,19 @@
global using System.IdentityModel.Tokens.Jwt;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using System.Text;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.EntityFrameworkCore.Diagnostics;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
global using Microsoft.IdentityModel.Tokens;
+5
View File
@@ -0,0 +1,5 @@
[*.cs]
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA2007.severity = none
+2 -1
View File
@@ -8,8 +8,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" />
<Using Include="AwesomeAssertions" /> <Using Include="AwesomeAssertions" />
<Using Include="Moq" />
<Using Include="Xunit" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+2
View File
@@ -5,6 +5,8 @@
<ItemGroup> <ItemGroup>
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" /> <PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" /> <PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -1,3 +1,4 @@
[*.cs] [*.cs]
dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1062.severity = none
@@ -8,15 +8,20 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AwesomeAssertions" /> <PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3.mtp-v2" /> <PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" /> <ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+35
View File
@@ -0,0 +1,35 @@
using System.Security.Cryptography;
using FiscalOS.Infra.Authentication;
namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureLogging(static c => c.ClearProviders());
builder.ConfigureTestServices(static c =>
{
var dbOpts = Options.Create(new AppDbContextOptions()
{
DatabaseFilePath = $"{Guid.NewGuid()}.db",
});
c.AddSingleton(dbOpts);
var jwtOpts = Options.Create(new JwtOptions()
{
Issuer = "TestIssuer",
Audience = "TestAudience",
Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
ExpiryInMinutes = 5,
});
c.AddSingleton(jwtOpts);
});
}
}
@@ -0,0 +1,53 @@
namespace FiscalOS.API.Tests.Integration;
public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>, IAsyncLifetime
{
public HttpClient Client => testApi.CreateClient();
public async ValueTask InitializeAsync()
{
await ExecuteDbContextAsync(static async context =>
{
await context.Database.EnsureCreatedAsync();
});
}
protected async Task ExecuteDbContextAsync(Func<DbContext, Task> action)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context);
}
protected async Task ExecuteDbContextAsync(Func<DbContext, IServiceProvider, Task> action)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, scope.ServiceProvider);
}
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, Task<T>> action)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context);
}
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, IServiceProvider, Task<T>> action)
{
await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, scope.ServiceProvider);
}
public async ValueTask DisposeAsync()
{
await ExecuteDbContextAsync(static async context =>
{
await context.Database.EnsureDeletedAsync();
});
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,176 @@
namespace FiscalOS.API.Tests.Integration;
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
{
[Theory]
[ClassData<LoginValidationTestCases>]
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
{
var req = new
{
username = tc.Username,
password = tc.Password,
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
problem!.Errors.Should().BeEquivalentTo(tc.ExpectedErrors);
}
[Fact]
public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails()
{
var req = new
{
username = "Test",
password = "@Password2",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
{
await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1")));
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
});
var req = new
{
username = "Stevan",
password = "@Password2",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtToken()
{
await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1")));
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
});
var req = new
{
username = "Stevan",
password = "@Password1",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await res.Content.ReadFromJsonAsync<Login.Response>(TestContext.Current.CancellationToken);
content!.AccessToken.Should().NotBeNullOrEmpty();
res.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue();
cookies.Should().NotBeNull();
}
}
public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
{
public LoginValidationTestCases()
{
Add(new LoginValidationTestCase(
"No username or password",
string.Empty,
string.Empty,
new Dictionary<string, string[]>()
{
["Username"] = ["The Username field is required."],
["Password"] = ["The Password field is required."]
}
));
Add(new LoginValidationTestCase(
"No username",
string.Empty,
"@Password1",
new Dictionary<string, string[]>()
{
["Username"] = ["The Username field is required."],
}
));
Add(new LoginValidationTestCase(
"No password",
"Stevan",
string.Empty,
new Dictionary<string, string[]>()
{
["Password"] = ["The Password field is required."]
}
));
}
}
public record LoginValidationTestCase : IXunitSerializable
{
public string Name { get; private set; } = string.Empty;
public string Username { get; private set; } = string.Empty;
public string Password { get; private set; } = string.Empty;
public Dictionary<string, string[]> ExpectedErrors { get; private set; } = [];
public override string ToString()
{
return Name;
}
public LoginValidationTestCase()
{
}
public LoginValidationTestCase(
string name,
string username,
string password,
Dictionary<string, string[]> expectedErrors
)
{
Name = name;
Username = username;
Password = password;
ExpectedErrors = expectedErrors;
}
public void Deserialize(IXunitSerializationInfo info)
{
Name = info.GetValue<string>(nameof(Name)) ?? string.Empty;
Username = info.GetValue<string>(nameof(Username)) ?? string.Empty;
Password = info.GetValue<string>(nameof(Password)) ?? string.Empty;
ExpectedErrors = info.GetValue<Dictionary<string, string[]>>(nameof(ExpectedErrors)) ?? [];
}
public void Serialize(IXunitSerializationInfo info)
{
info.AddValue(nameof(Name), Name);
info.AddValue(nameof(Username), Username);
info.AddValue(nameof(Password), Password);
info.AddValue(nameof(ExpectedErrors), ExpectedErrors);
}
}
+14 -1
View File
@@ -1,5 +1,18 @@
global using System.Net;
global using System.Net.Http.Json;
global using FiscalOS.API.Tests.Infra; global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Data;
global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.Logging; global using Microsoft.AspNetCore.TestHost;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
global using Xunit.Sdk;
@@ -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,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>FiscalOS.Infra.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
</ItemGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="Test">
<Exec
Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
</Target>
</Project>
@@ -0,0 +1,42 @@
using FiscalOS.Infra.Authentication;
namespace FiscalOS.Infra.Tests.Unit;
public class PasswordHasherTests
{
private readonly PasswordHasher _sut = PasswordHasher.New();
[Fact]
public void Hash_WhenGivenPassword_ShouldReturnHashedPassword()
{
var password = "SecurePassword123!";
var result = _sut.Hash(password);
result.Should().NotBeNullOrEmpty();
result.Should().NotBe(password);
}
[Fact]
public void Verify_WhenGivenCorrectPassword_ShouldReturnTrue()
{
var password = "SecurePassword123!";
var hashedPassword = _sut.Hash(password);
var result = _sut.Verify(password, hashedPassword);
result.Should().BeTrue();
}
[Fact]
public void Verify_WhenGivenIncorrectPassword_ShouldReturnFalse()
{
var password = "SecurePassword123!";
var wrongPassword = "WrongPassword!";
var hashedPassword = _sut.Hash(password);
var result = _sut.Verify(wrongPassword, hashedPassword);
result.Should().BeFalse();
}
}
@@ -0,0 +1,75 @@
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();
}
}
+11
View File
@@ -0,0 +1,11 @@
global using System.Globalization;
global using System.IdentityModel.Tokens.Jwt;
global using System.Security.Cryptography;
global using AwesomeAssertions.Primitives;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Tests.Assertions;
global using Microsoft.Extensions.Options;
@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}