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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[*.cs]
|
||||||
|
|
||||||
|
dotnet_diagnostic.CA1062.severity = none
|
||||||
|
dotnet_diagnostic.CA2007.severity = none
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
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();
|
||||||
@@ -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;
|
||||||
@@ -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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
|
||||||
<Using Include="AwesomeAssertions" />
|
<Using Include="AwesomeAssertions" />
|
||||||
|
<Using Include="Moq" />
|
||||||
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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