Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc45ae2ff | ||
|
|
99db5696ff | ||
|
|
f9c75b6778 | ||
|
|
ae623a1881 | ||
|
|
b58a5118a1 | ||
|
|
cf4b4849c7 | ||
|
|
e6c5863180 | ||
|
|
28bb1dad66 | ||
|
|
31520b7cfa | ||
|
|
0d59045a5e | ||
|
|
7822088138 | ||
|
|
d1a4de8fdb | ||
|
|
1221d971a6 | ||
|
|
10b6034aca |
@@ -4,7 +4,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
<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">
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using FiscalOS.Core.Identity;
|
||||||
|
|
||||||
|
namespace FiscalOS.API.Http;
|
||||||
|
|
||||||
|
internal static class HttpContextExtensions
|
||||||
|
{
|
||||||
|
private const string RefreshTokenCookieName = "fiscalos_refresh_cookie";
|
||||||
|
|
||||||
|
public static Guid GetUserId(this HttpContext context)
|
||||||
|
{
|
||||||
|
var id = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
|
||||||
|
return Guid.TryParse(id, out var userId) ? userId : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetRefreshTokenCookie(this HttpContext context, RefreshToken token)
|
||||||
|
{
|
||||||
|
context.Response.Cookies.Append(
|
||||||
|
RefreshTokenCookieName,
|
||||||
|
token.Token,
|
||||||
|
new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Expires = token.ExpiresAt,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Secure = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetRefreshTokenFromCookie(this HttpContext context)
|
||||||
|
{
|
||||||
|
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,22 +32,13 @@ internal static class Endpoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateAccessToken(user);
|
var accessToken = tokenService.GenerateAccessToken(user);
|
||||||
|
|
||||||
var refreshToken = tokenService.GenerateRefreshToken(user);
|
var refreshToken = tokenService.GenerateRefreshToken(user);
|
||||||
await appDbContext.RefreshTokens.AddAsync(refreshToken);
|
|
||||||
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
await appDbContext.SaveChangesAsync();
|
await appDbContext.SaveChangesAsync();
|
||||||
|
|
||||||
httpContext.Response.Cookies.Append(
|
httpContext.SetRefreshTokenCookie(refreshToken);
|
||||||
"fiscalos_refresh_cookie",
|
|
||||||
refreshToken.Token,
|
|
||||||
new CookieOptions
|
|
||||||
{
|
|
||||||
HttpOnly = true,
|
|
||||||
Expires = refreshToken.ExpiresAt,
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
Secure = true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Results.Ok(Response.From(accessToken));
|
return Results.Ok(Response.From(accessToken));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
|
using FiscalOS.Infra.Authentication;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddValidation();
|
builder.Services.AddValidation();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
builder.Services.AddInfrastructure();
|
builder.Services.AddInfrastructure();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(static o =>
|
||||||
|
{
|
||||||
|
o.DefaultAuthenticateScheme = Schemes.Default;
|
||||||
|
o.DefaultChallengeScheme = Schemes.Default;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(Schemes.Default)
|
||||||
|
.AddJwtBearer(Schemes.AllowExpiredTokens);
|
||||||
|
|
||||||
|
builder.Services.AddAuthorizationBuilder()
|
||||||
|
.AddPolicy(Schemes.Default, static policy =>
|
||||||
|
{
|
||||||
|
policy.AuthenticationSchemes = [Schemes.Default];
|
||||||
|
policy.RequireAuthenticatedUser();
|
||||||
|
})
|
||||||
|
.AddPolicy(Schemes.AllowExpiredTokens, static policy =>
|
||||||
|
{
|
||||||
|
policy.AuthenticationSchemes = [Schemes.AllowExpiredTokens];
|
||||||
|
policy.RequireAuthenticatedUser();
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseStatusCodePages();
|
||||||
|
|
||||||
app.MapLoginEndpoint();
|
app.MapLoginEndpoint();
|
||||||
|
|
||||||
|
app.MapRefreshEndpoint()
|
||||||
|
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace FiscalOS.API.Refresh;
|
||||||
|
|
||||||
|
internal static class Endpoint
|
||||||
|
{
|
||||||
|
private const string Route = "/refresh";
|
||||||
|
|
||||||
|
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
|
||||||
|
{
|
||||||
|
return app.MapPost(Route, HandleAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
[FromServices] AppDbContext appDbContext,
|
||||||
|
[FromServices] TimeProvider timeProvider,
|
||||||
|
[FromServices] ITokenGenerator tokenGenerator
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var requestUserId = httpContext.GetUserId();
|
||||||
|
var token = httpContext.GetRefreshTokenFromCookie();
|
||||||
|
|
||||||
|
var refreshToken = await appDbContext.RefreshTokens
|
||||||
|
.Include(t => t.User)
|
||||||
|
.SingleOrDefaultAsync(t => t.Token == token);
|
||||||
|
|
||||||
|
if (refreshToken is null || refreshToken.User is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken.UserId != requestUserId)
|
||||||
|
{
|
||||||
|
await appDbContext.RefreshTokens
|
||||||
|
.Where(t => t.UserId == refreshToken.UserId)
|
||||||
|
.ExecuteUpdateAsync(t => t.SetProperty(t => t.Revoked, true));
|
||||||
|
|
||||||
|
await appDbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
if (refreshToken.Revoked || refreshToken.IsExpired(now))
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessToken = tokenGenerator.GenerateAccessToken(refreshToken.User);
|
||||||
|
var newRefreshToken = tokenGenerator.GenerateRefreshToken(refreshToken.User);
|
||||||
|
|
||||||
|
refreshToken.Revoke();
|
||||||
|
await appDbContext.RefreshTokens.AddAsync(newRefreshToken);
|
||||||
|
await appDbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
httpContext.SetRefreshTokenCookie(newRefreshToken);
|
||||||
|
|
||||||
|
return Results.Ok(Response.From(accessToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace FiscalOS.API.Refresh;
|
||||||
|
|
||||||
|
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 @@
|
|||||||
global using System.ComponentModel.DataAnnotations;
|
global using System.ComponentModel.DataAnnotations;
|
||||||
|
global using System.Security.Claims;
|
||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
global using FiscalOS.API.Http;
|
||||||
global using FiscalOS.API.Login;
|
global using FiscalOS.API.Login;
|
||||||
|
global using FiscalOS.API.Refresh;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
global using FiscalOS.Infra.DependencyInjection;
|
global using FiscalOS.Infra.DependencyInjection;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ public sealed class RefreshToken : Entity
|
|||||||
public string Token { get; init; } = string.Empty;
|
public string Token { get; init; } = string.Empty;
|
||||||
public DateTimeOffset ExpiresAt { get; init; }
|
public DateTimeOffset ExpiresAt { get; init; }
|
||||||
public Guid UserId { get; init; }
|
public Guid UserId { get; init; }
|
||||||
public User User { get; init; } = User.New();
|
public User? User { get; init; }
|
||||||
|
public bool Revoked { get; set; }
|
||||||
|
|
||||||
private RefreshToken()
|
private RefreshToken()
|
||||||
{
|
{
|
||||||
@@ -20,4 +21,27 @@ public sealed class RefreshToken : Entity
|
|||||||
ExpiresAt = expiresAt,
|
ExpiresAt = expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = token,
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Revoke()
|
||||||
|
{
|
||||||
|
Revoked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
return now >= ExpiresAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,12 @@ namespace FiscalOS.Core.Identity;
|
|||||||
|
|
||||||
public sealed class User : Entity
|
public sealed class User : Entity
|
||||||
{
|
{
|
||||||
|
private readonly List<RefreshToken> _refreshTokens = [];
|
||||||
|
|
||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
public string HashedPassword { get; init; } = string.Empty;
|
public string HashedPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
|
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
|
||||||
|
|
||||||
private User()
|
private User()
|
||||||
{
|
{
|
||||||
@@ -24,4 +26,11 @@ public sealed class User : Entity
|
|||||||
HashedPassword = hashedPassword,
|
HashedPassword = hashedPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddRefreshToken(RefreshToken refreshToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(refreshToken);
|
||||||
|
|
||||||
|
_refreshTokens.Add(refreshToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
[*.cs]
|
[*.cs]
|
||||||
|
|
||||||
dotnet_diagnostic.CA1062.severity = none
|
dotnet_diagnostic.CA1062.severity = none
|
||||||
|
dotnet_diagnostic.CA5404.severity = none
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
namespace FiscalOS.Infra.Authentication;
|
namespace FiscalOS.Infra.Authentication;
|
||||||
|
|
||||||
public sealed record JwtOptions
|
public sealed record JwtOptions
|
||||||
@@ -6,6 +8,8 @@ public sealed record JwtOptions
|
|||||||
public string Audience { get; init; } = string.Empty;
|
public string Audience { get; init; } = string.Empty;
|
||||||
public string Secret { get; init; } = string.Empty;
|
public string Secret { get; init; } = string.Empty;
|
||||||
public int ExpiryInMinutes { get; init; } = 5;
|
public int ExpiryInMinutes { get; init; } = 5;
|
||||||
|
|
||||||
|
public SymmetricSecurityKey Key => new(Encoding.UTF8.GetBytes(Secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
|
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
|
||||||
@@ -23,3 +27,32 @@ public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
|
|||||||
_configuration.GetSection(SectionName).Bind(options);
|
_configuration.GetSection(SectionName).Bind(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class JwtBearerOptionsSetup(IOptions<JwtOptions> jwtOptions) : IConfigureNamedOptions<JwtBearerOptions>
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
|
||||||
|
|
||||||
|
public void Configure(string? name, JwtBearerOptions options)
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new()
|
||||||
|
{
|
||||||
|
ValidIssuer = _jwtOptions.Issuer,
|
||||||
|
ValidAudience = _jwtOptions.Audience,
|
||||||
|
IssuerSigningKey = _jwtOptions.Key,
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (name is Schemes.AllowExpiredTokens)
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters.ValidateLifetime = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(JwtBearerOptions options)
|
||||||
|
{
|
||||||
|
Configure(Options.DefaultName, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Authentication;
|
||||||
|
|
||||||
|
public static class Schemes
|
||||||
|
{
|
||||||
|
public const string Default = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
public const string AllowExpiredTokens = "AllowExpiredTokens";
|
||||||
|
}
|
||||||
@@ -33,7 +33,6 @@ public sealed class TokenGenerator : ITokenGenerator
|
|||||||
public string GenerateAccessToken(User user)
|
public string GenerateAccessToken(User user)
|
||||||
{
|
{
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
var secretKeyBytes = Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret);
|
|
||||||
var issuedAt = _timeProvider.GetUtcNow();
|
var issuedAt = _timeProvider.GetUtcNow();
|
||||||
var expiresAt = issuedAt.AddMinutes(_jwtOptions.Value.ExpiryInMinutes);
|
var expiresAt = issuedAt.AddMinutes(_jwtOptions.Value.ExpiryInMinutes);
|
||||||
List<Claim> claims = [
|
List<Claim> claims = [
|
||||||
@@ -49,7 +48,7 @@ public sealed class TokenGenerator : ITokenGenerator
|
|||||||
Issuer = _jwtOptions.Value.Issuer,
|
Issuer = _jwtOptions.Value.Issuer,
|
||||||
Audience = _jwtOptions.Value.Audience,
|
Audience = _jwtOptions.Value.Audience,
|
||||||
SigningCredentials = new(
|
SigningCredentials = new(
|
||||||
new SymmetricSecurityKey(secretKeyBytes),
|
_jwtOptions.Value.Key,
|
||||||
SecurityAlgorithms.HmacSha256Signature
|
SecurityAlgorithms.HmacSha256Signature
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace FiscalOS.Infra.Authorization;
|
||||||
|
|
||||||
|
internal sealed class ProblemDetailsAuthResultHandler : IAuthorizationMiddlewareResultHandler
|
||||||
|
{
|
||||||
|
public Task HandleAsync(
|
||||||
|
RequestDelegate next,
|
||||||
|
HttpContext context,
|
||||||
|
AuthorizationPolicy policy,
|
||||||
|
PolicyAuthorizationResult authorizeResult
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (authorizeResult.Succeeded)
|
||||||
|
{
|
||||||
|
return next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.StatusCode = authorizeResult.Forbidden
|
||||||
|
? StatusCodes.Status403Forbidden
|
||||||
|
: StatusCodes.Status401Unauthorized;
|
||||||
|
|
||||||
|
context.Response.ContentType = "application/problem+json";
|
||||||
|
|
||||||
|
var problemDetails = authorizeResult.Forbidden
|
||||||
|
? new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Forbidden",
|
||||||
|
Status = StatusCodes.Status403Forbidden,
|
||||||
|
Detail = "You do not have permission to access this resource."
|
||||||
|
}
|
||||||
|
: new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Unauthorized",
|
||||||
|
Status = StatusCodes.Status401Unauthorized,
|
||||||
|
Detail = "Authentication is required to access this resource."
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(problemDetails);
|
||||||
|
|
||||||
|
return context.Response.WriteAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,6 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
|||||||
|
|
||||||
modelBuilder.Entity<User>(static eb =>
|
modelBuilder.Entity<User>(static eb =>
|
||||||
{
|
{
|
||||||
|
|
||||||
eb.HasMany(static u => u.RefreshTokens)
|
eb.HasMany(static u => u.RefreshTokens)
|
||||||
.WithOne(static t => t.User)
|
.WithOne(static t => t.User)
|
||||||
.HasForeignKey(static t => t.UserId)
|
.HasForeignKey(static t => t.UserId)
|
||||||
@@ -56,10 +55,12 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
|||||||
|
|
||||||
eb.Property(static u => u.HashedPassword);
|
eb.Property(static u => u.HashedPassword);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(static eb =>
|
modelBuilder.Entity<RefreshToken>(static eb =>
|
||||||
{
|
{
|
||||||
eb.Property(static t => t.Id);
|
eb.Property(static t => t.Id);
|
||||||
eb.Property(static t => t.ExpiresAt);
|
eb.Property(static t => t.ExpiresAt);
|
||||||
|
eb.Property(static t => t.UserId);
|
||||||
|
|
||||||
eb.Property(static t => t.Token);
|
eb.Property(static t => t.Token);
|
||||||
eb.HasIndex(static t => t.Token).IsUnique();
|
eb.HasIndex(static t => t.Token).IsUnique();
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
||||||
|
|
||||||
services.AddSingleton(TimeProvider.System);
|
services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
services.ConfigureOptions<JwtOptionsSetup>();
|
services.ConfigureOptions<JwtOptionsSetup>();
|
||||||
|
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||||
|
|
||||||
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
|
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
|
||||||
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
|
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
|
||||||
|
|
||||||
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
||||||
services.AddDbContext<AppDbContext>();
|
services.AddDbContext<AppDbContext>();
|
||||||
services.AddHostedService<MigrationService>();
|
services.AddHostedService<MigrationService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+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("20260203115122_AddUserIdPropToRefreshToken")]
|
||||||
|
partial class AddUserIdPropToRefreshToken
|
||||||
|
{
|
||||||
|
/// <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,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserIdPropToRefreshToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// <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("20260203164938_AddRevokedPropertyToRefreshToken")]
|
||||||
|
partial class AddRevokedPropertyToRefreshToken
|
||||||
|
{
|
||||||
|
/// <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<bool>("Revoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRevokedPropertyToRefreshToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "Revoked",
|
||||||
|
table: "RefreshTokens",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Revoked",
|
||||||
|
table: "RefreshTokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ namespace FiscalOS.Infra.Migrations
|
|||||||
b.Property<DateTimeOffset>("ExpiresAt")
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Revoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Token")
|
b.Property<string>("Token")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ global using System.IdentityModel.Tokens.Jwt;
|
|||||||
global using System.Security.Claims;
|
global using System.Security.Claims;
|
||||||
global using System.Security.Cryptography;
|
global using System.Security.Cryptography;
|
||||||
global using System.Text;
|
global using System.Text;
|
||||||
|
global using System.Text.Json;
|
||||||
|
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Data;
|
global using FiscalOS.Core.Data;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
global using FiscalOS.Infra.Authentication;
|
global using FiscalOS.Infra.Authentication;
|
||||||
|
global using FiscalOS.Infra.Authorization;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
|
|
||||||
|
global using Microsoft.AspNetCore.Authorization;
|
||||||
|
global using Microsoft.AspNetCore.Authorization.Policy;
|
||||||
|
global using Microsoft.AspNetCore.Http;
|
||||||
|
global using Microsoft.AspNetCore.Mvc;
|
||||||
global using Microsoft.EntityFrameworkCore;
|
global using Microsoft.EntityFrameworkCore;
|
||||||
global using Microsoft.EntityFrameworkCore.Diagnostics;
|
global using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
global using Microsoft.Extensions.Configuration;
|
global using Microsoft.Extensions.Configuration;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
namespace FiscalOS.API.Tests.Assertions;
|
||||||
|
|
||||||
|
internal static class HttpResponseExtensions
|
||||||
|
{
|
||||||
|
public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessageAssertions(instance, AssertionChain.GetOrCreate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class HttpResponseMessageAssertions(
|
||||||
|
HttpResponseMessage instance,
|
||||||
|
AssertionChain assertionChain
|
||||||
|
) : ReferenceTypeAssertions<HttpResponseMessage, HttpResponseMessageAssertions>(instance, assertionChain)
|
||||||
|
{
|
||||||
|
private readonly AssertionChain _chain = assertionChain;
|
||||||
|
|
||||||
|
protected override string Identifier => "HttpResponseMessage";
|
||||||
|
|
||||||
|
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, T>> BeJsonContentOfType<T>(
|
||||||
|
HttpStatusCode expectedStatusCode
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
|
||||||
|
.FailWith(
|
||||||
|
"Expected response to be application/json, but found {0}",
|
||||||
|
Subject.Content.Headers.ContentType?.MediaType
|
||||||
|
);
|
||||||
|
|
||||||
|
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
|
||||||
|
.FailWith(
|
||||||
|
"Expected response status code to be {0}, but found {1}",
|
||||||
|
expectedStatusCode,
|
||||||
|
Subject.StatusCode
|
||||||
|
);
|
||||||
|
|
||||||
|
var content = await Subject.Content.ReadFromJsonAsync<T>();
|
||||||
|
|
||||||
|
_chain.ForCondition(content is not null)
|
||||||
|
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
|
||||||
|
|
||||||
|
return new AndWhichConstraint<HttpResponseMessageAssertions, T>(this, content!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AndConstraint<HttpResponseMessageAssertions> HaveSetCookieHeader(string cookieName)
|
||||||
|
{
|
||||||
|
_chain.ForCondition(Subject.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders))
|
||||||
|
.FailWith("Expected response to have 'Set-Cookie' header, but it was not found.");
|
||||||
|
|
||||||
|
var hasCookie = setCookieHeaders!.Any(header => header.StartsWith(cookieName + "=", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
_chain.ForCondition(hasCookie)
|
||||||
|
.FailWith($"Expected response to have 'Set-Cookie' header for cookie '{cookieName}', but it was not found.");
|
||||||
|
|
||||||
|
return new AndConstraint<HttpResponseMessageAssertions>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>> BeProblemDetails(HttpStatusCode expectedStatusCode)
|
||||||
|
{
|
||||||
|
var problem = await ValidateAndDeserialize<ProblemDetails>(expectedStatusCode);
|
||||||
|
return new AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>(this, problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>> BeValidationProblemDetails(
|
||||||
|
IDictionary<string, string[]> expectedErrors,
|
||||||
|
HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var problem = await ValidateAndDeserialize<ValidationProblemDetails>(expectedStatusCode);
|
||||||
|
|
||||||
|
_chain.ForCondition(problem.Errors is not null)
|
||||||
|
.FailWith("Expected ValidationProblemDetails to contain errors, but the Errors dictionary was null.");
|
||||||
|
|
||||||
|
problem.Errors.Should().BeEquivalentTo(expectedErrors, "the validation errors should match the expected dictionary");
|
||||||
|
|
||||||
|
return new AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>(this, problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails
|
||||||
|
{
|
||||||
|
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
|
||||||
|
.FailWith(
|
||||||
|
"Expected response to be application/problem+json, but found {0}",
|
||||||
|
Subject.Content.Headers.ContentType?.MediaType
|
||||||
|
);
|
||||||
|
|
||||||
|
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
|
||||||
|
.FailWith(
|
||||||
|
"Expected response status code to be {0}, but found {1}",
|
||||||
|
expectedStatusCode,
|
||||||
|
Subject.StatusCode
|
||||||
|
);
|
||||||
|
|
||||||
|
var problem = await Subject.Content.ReadFromJsonAsync<T>();
|
||||||
|
|
||||||
|
_chain.ForCondition(problem is not null)
|
||||||
|
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
|
||||||
|
|
||||||
|
return problem!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace FiscalOS.API.Tests.Infra;
|
||||||
|
|
||||||
|
internal sealed class JwtTokenBuilder
|
||||||
|
{
|
||||||
|
private const string Issuer = "TestIssuer";
|
||||||
|
private const string Audience = "TestAudience";
|
||||||
|
private const int ExpiryInMinutes = 5;
|
||||||
|
private static readonly string Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
private readonly List<Claim> _claims = [];
|
||||||
|
private DateTimeOffset _expiresAt = DateTimeOffset.UtcNow.AddMinutes(ExpiryInMinutes);
|
||||||
|
|
||||||
|
public static JwtOptions DefaultJwtOptions => new()
|
||||||
|
{
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
Secret = Secret,
|
||||||
|
ExpiryInMinutes = ExpiryInMinutes,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static JwtTokenBuilder New()
|
||||||
|
{
|
||||||
|
return new JwtTokenBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtTokenBuilder WithClaim(string type, string value)
|
||||||
|
{
|
||||||
|
_claims.Add(new(type, value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtTokenBuilder WithExpiresAt(DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
_expiresAt = expiresAt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Build()
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var issuedAt = _expiresAt.AddMinutes(-ExpiryInMinutes);
|
||||||
|
|
||||||
|
var descriptor = new SecurityTokenDescriptor()
|
||||||
|
{
|
||||||
|
Subject = new(_claims),
|
||||||
|
NotBefore = issuedAt.UtcDateTime,
|
||||||
|
IssuedAt = issuedAt.UtcDateTime,
|
||||||
|
Expires = _expiresAt.UtcDateTime,
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
SigningCredentials = new(
|
||||||
|
DefaultJwtOptions.Key,
|
||||||
|
SecurityAlgorithms.HmacSha256Signature
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var securityToken = tokenHandler.CreateToken(descriptor);
|
||||||
|
var jwtToken = tokenHandler.WriteToken(securityToken);
|
||||||
|
|
||||||
|
return jwtToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
using FiscalOS.Infra.Authentication;
|
|
||||||
|
|
||||||
namespace FiscalOS.API.Tests.Infra;
|
namespace FiscalOS.API.Tests.Infra;
|
||||||
|
|
||||||
public class TestApi : WebApplicationFactory<Program>
|
public class TestApi : WebApplicationFactory<Program>
|
||||||
@@ -14,22 +10,12 @@ public class TestApi : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
builder.ConfigureTestServices(static c =>
|
builder.ConfigureTestServices(static c =>
|
||||||
{
|
{
|
||||||
var dbOpts = Options.Create(new AppDbContextOptions()
|
c.AddSingleton(Options.Create(new AppDbContextOptions()
|
||||||
{
|
{
|
||||||
DatabaseFilePath = $"{Guid.NewGuid()}.db",
|
DatabaseFilePath = $"{Guid.NewGuid()}.db",
|
||||||
});
|
}));
|
||||||
|
|
||||||
c.AddSingleton(dbOpts);
|
c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions));
|
||||||
|
|
||||||
var jwtOpts = Options.Create(new JwtOptions()
|
|
||||||
{
|
|
||||||
Issuer = "TestIssuer",
|
|
||||||
Audience = "TestAudience",
|
|
||||||
Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
|
|
||||||
ExpiryInMinutes = 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
c.AddSingleton(jwtOpts);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ namespace FiscalOS.API.Tests.Integration;
|
|||||||
|
|
||||||
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||||
{
|
{
|
||||||
|
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[ClassData<LoginValidationTestCases>]
|
[ClassData<LoginValidationTestCases>]
|
||||||
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
||||||
@@ -12,13 +14,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
password = tc.Password,
|
password = tc.Password,
|
||||||
};
|
};
|
||||||
|
|
||||||
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
|
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
|
||||||
|
|
||||||
var problem = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
problem!.Errors.Should().BeEquivalentTo(tc.ExpectedErrors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -30,9 +28,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
password = "@Password2",
|
password = "@Password2",
|
||||||
};
|
};
|
||||||
|
|
||||||
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
|
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -53,13 +51,13 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
password = "@Password2",
|
password = "@Password2",
|
||||||
};
|
};
|
||||||
|
|
||||||
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
|
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtToken()
|
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
|
||||||
{
|
{
|
||||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
{
|
{
|
||||||
@@ -76,18 +74,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
password = "@Password1",
|
password = "@Password1",
|
||||||
};
|
};
|
||||||
|
|
||||||
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
|
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||||
|
await res.Should().BeJsonContentOfType<Login.Response>(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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
namespace FiscalOS.API.Tests.Integration;
|
||||||
|
|
||||||
|
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||||
|
{
|
||||||
|
private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsync(RefreshUri, null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||||
|
{
|
||||||
|
var jwt = JwtTokenBuilder.New()
|
||||||
|
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
request.Headers.Add("Cookie", "fiscalos_refresh_cookie=nonexistenttoken");
|
||||||
|
|
||||||
|
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
|
||||||
|
{
|
||||||
|
var (users, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
|
{
|
||||||
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
|
||||||
|
var user1 = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||||
|
var user2 = User.From("User2", passwordHasher.Hash("@Password2"));
|
||||||
|
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
|
||||||
|
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
|
||||||
|
user2.AddRefreshToken(refreshToken1);
|
||||||
|
user2.AddRefreshToken(refreshToken2);
|
||||||
|
|
||||||
|
context.Add(user1);
|
||||||
|
context.Add(user2);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
return (new User[] { user1, user2 }, refreshToken1);
|
||||||
|
});
|
||||||
|
|
||||||
|
var jwt = JwtTokenBuilder.New()
|
||||||
|
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||||
|
|
||||||
|
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
|
||||||
|
|
||||||
|
var unrevokedTokensCountForUser2 = await ExecuteDbContextAsync(
|
||||||
|
async context => await context.Set<RefreshToken>()
|
||||||
|
.Include(t => t.User)
|
||||||
|
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
|
||||||
|
.CountAsync()
|
||||||
|
);
|
||||||
|
|
||||||
|
unrevokedTokensCountForUser2.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||||
|
{
|
||||||
|
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
|
{
|
||||||
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
|
||||||
|
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||||
|
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||||
|
refreshToken.Revoke();
|
||||||
|
|
||||||
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
context.Add(user);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
return (user, refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var jwt = JwtTokenBuilder.New()
|
||||||
|
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||||
|
|
||||||
|
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||||
|
{
|
||||||
|
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
|
{
|
||||||
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||||
|
|
||||||
|
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||||
|
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
|
||||||
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
context.Add(user);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
return (user, refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var jwt = JwtTokenBuilder.New()
|
||||||
|
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||||
|
|
||||||
|
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(5)]
|
||||||
|
[InlineData(-5)]
|
||||||
|
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
|
||||||
|
{
|
||||||
|
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
|
{
|
||||||
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||||
|
|
||||||
|
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||||
|
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||||
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
context.Add(user);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
return (user, refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var jwt = JwtTokenBuilder.New()
|
||||||
|
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||||
|
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||||
|
|
||||||
|
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||||
|
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var oldRefreshTokenInDb = await ExecuteDbContextAsync(
|
||||||
|
async context => await context.Set<RefreshToken>()
|
||||||
|
.Include(t => t.User)
|
||||||
|
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
|
||||||
|
.SingleOrDefaultAsync()
|
||||||
|
);
|
||||||
|
|
||||||
|
oldRefreshTokenInDb.Should().NotBeNull();
|
||||||
|
|
||||||
|
var newRefreshTokenInDb = await ExecuteDbContextAsync(
|
||||||
|
async context => await context.Set<RefreshToken>()
|
||||||
|
.Include(t => t.User)
|
||||||
|
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
|
||||||
|
.SingleOrDefaultAsync()
|
||||||
|
);
|
||||||
|
|
||||||
|
newRefreshTokenInDb.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
global using System.IdentityModel.Tokens.Jwt;
|
||||||
global using System.Net;
|
global using System.Net;
|
||||||
|
global using System.Net.Http.Headers;
|
||||||
global using System.Net.Http.Json;
|
global using System.Net.Http.Json;
|
||||||
|
global using System.Security.Claims;
|
||||||
|
global using System.Security.Cryptography;
|
||||||
|
|
||||||
|
global using AwesomeAssertions.Execution;
|
||||||
|
global using AwesomeAssertions.Primitives;
|
||||||
|
global using FiscalOS.API.Tests.Assertions;
|
||||||
global using FiscalOS.API.Tests.Infra;
|
global using FiscalOS.API.Tests.Infra;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Infra.Authentication;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
|
|
||||||
global using Microsoft.AspNetCore.Hosting;
|
global using Microsoft.AspNetCore.Hosting;
|
||||||
@@ -14,5 +22,6 @@ global using Microsoft.EntityFrameworkCore;
|
|||||||
global using Microsoft.Extensions.DependencyInjection;
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
global using Microsoft.Extensions.Logging;
|
global using Microsoft.Extensions.Logging;
|
||||||
global using Microsoft.Extensions.Options;
|
global using Microsoft.Extensions.Options;
|
||||||
|
global using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
global using Xunit.Sdk;
|
global using Xunit.Sdk;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace FiscalOS.Infra.Tests.Assertions;
|
namespace FiscalOS.Infra.Tests.Assertions;
|
||||||
|
|
||||||
internal static class JwtTokenAssertionExtensions
|
internal static class JwtTokenAssertions
|
||||||
{
|
{
|
||||||
public static AndConstraint<string> HaveClaim(
|
public static AndConstraint<string> HaveClaim(
|
||||||
this StringAssertions assertions,
|
this StringAssertions assertions,
|
||||||
|
|||||||
Reference in New Issue
Block a user