Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef410d71b | ||
|
|
fb7750328b | ||
|
|
35557a8aa7 | ||
|
|
5a5efbefd7 | ||
|
|
dcc5b2e0f0 | ||
|
|
7d5fd4149c | ||
|
|
abc6a7655e | ||
|
|
178f03459b | ||
|
|
b3bfa64b96 | ||
|
|
098aa23c62 | ||
|
|
815b6aa57c | ||
|
|
3a1a7138c5 | ||
|
|
ee6abc4559 | ||
|
|
b535c0a121 | ||
|
|
822da7368c | ||
|
|
7c6e43d62d | ||
|
|
48913fc434 | ||
|
|
c01d0b5daa | ||
|
|
7a7ab90155 | ||
|
|
81e5b9ed6a | ||
|
|
b5ea9a514f | ||
|
|
c8c45f6a83 | ||
|
|
ce45244813 | ||
|
|
e479ba4c8a | ||
|
|
bf7b1b3fc8 | ||
|
|
872eb3e95d | ||
|
|
725392227e | ||
|
|
093ee2c357 | ||
|
|
a1d22b0ec2 | ||
|
|
2b91e3b791 | ||
|
|
8e40b15cb2 | ||
|
|
8012ecaa73 |
+3
-1
@@ -59,7 +59,7 @@ dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
dotnet_style_prefer_compound_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_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
@@ -81,7 +81,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none
|
||||
|
||||
# analyzer settings
|
||||
dotnet_diagnostic.IDE0058.severity = none
|
||||
dotnet_diagnostic.IDE0100.severity = none
|
||||
dotnet_diagnostic.CA1515.severity = none
|
||||
dotnet_diagnostic.CA1848.severity = none
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
@@ -11,6 +11,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -52,5 +108,9 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{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
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<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>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1062.severity = none
|
||||
dotnet_diagnostic.CA2007.severity = none
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddValidation();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddInfrastructure();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -11,4 +14,6 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.MapLoginEndpoint();
|
||||
|
||||
app.Run();
|
||||
@@ -1,5 +1,10 @@
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
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.EntityFrameworkCore;
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AppDbContextOptions": {
|
||||
"DatabaseFilePath": "DatabaseFilePath"
|
||||
},
|
||||
"JwtOptions": {
|
||||
"Audience": "Audience",
|
||||
"Issuer": "Issuer",
|
||||
"Secret": "Secret",
|
||||
"ExpiryInMinutes": "ExpiryInMinutes"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1303.severity = none
|
||||
dotnet_diagnostic.CA2007.severity = none
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
global using FiscalOS.Core.Data;
|
||||
|
||||
global using FiscalOS.Core.Identity;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+47
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.CA1062.severity = none
|
||||
dotnet_diagnostic.CA2007.severity = none
|
||||
@@ -8,8 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AwesomeAssertions" />
|
||||
<Using Include="Moq" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,3 +1,4 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.CA1062.severity = none
|
||||
|
||||
@@ -8,15 +8,20 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AwesomeAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
global using System.Net;
|
||||
global using System.Net.Http.Json;
|
||||
|
||||
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.Mvc;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user