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>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -13,4 +12,4 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<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 refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
await appDbContext.RefreshTokens.AddAsync(refreshToken);
|
||||
|
||||
user.AddRefreshToken(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
|
||||
}
|
||||
);
|
||||
httpContext.SetRefreshTokenCookie(refreshToken);
|
||||
|
||||
return Results.Ok(Response.From(accessToken));
|
||||
}
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
using FiscalOS.Infra.Authentication;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddValidation();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
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();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.MapLoginEndpoint();
|
||||
|
||||
app.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
|
||||
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,10 +1,13 @@
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Security.Claims;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using FiscalOS.API.Http;
|
||||
global using FiscalOS.API.Login;
|
||||
global using FiscalOS.API.Refresh;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Infra.DependencyInjection;
|
||||
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -5,7 +5,8 @@ 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();
|
||||
public User? User { get; init; }
|
||||
public bool Revoked { get; set; }
|
||||
|
||||
private RefreshToken()
|
||||
{
|
||||
@@ -20,4 +21,27 @@ public sealed class RefreshToken : Entity
|
||||
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
|
||||
{
|
||||
private readonly List<RefreshToken> _refreshTokens = [];
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string HashedPassword { get; init; } = string.Empty;
|
||||
|
||||
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
|
||||
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
|
||||
|
||||
private User()
|
||||
{
|
||||
@@ -24,4 +26,11 @@ public sealed class User : Entity
|
||||
HashedPassword = hashedPassword,
|
||||
};
|
||||
}
|
||||
|
||||
public void AddRefreshToken(RefreshToken refreshToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(refreshToken);
|
||||
|
||||
_refreshTokens.Add(refreshToken);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1062.severity = none
|
||||
dotnet_diagnostic.CA5404.severity = none
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
namespace FiscalOS.Infra.Authentication;
|
||||
|
||||
public sealed record JwtOptions
|
||||
@@ -6,6 +8,8 @@ public sealed record JwtOptions
|
||||
public string Audience { get; init; } = string.Empty;
|
||||
public string Secret { get; init; } = string.Empty;
|
||||
public int ExpiryInMinutes { get; init; } = 5;
|
||||
|
||||
public SymmetricSecurityKey Key => new(Encoding.UTF8.GetBytes(Secret));
|
||||
}
|
||||
|
||||
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
|
||||
@@ -22,4 +26,33 @@ public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
|
||||
{
|
||||
_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)
|
||||
{
|
||||
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 = [
|
||||
@@ -49,7 +48,7 @@ public sealed class TokenGenerator : ITokenGenerator
|
||||
Issuer = _jwtOptions.Value.Issuer,
|
||||
Audience = _jwtOptions.Value.Audience,
|
||||
SigningCredentials = new(
|
||||
new SymmetricSecurityKey(secretKeyBytes),
|
||||
_jwtOptions.Value.Key,
|
||||
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 =>
|
||||
{
|
||||
|
||||
eb.HasMany(static u => u.RefreshTokens)
|
||||
.WithOne(static t => t.User)
|
||||
.HasForeignKey(static t => t.UserId)
|
||||
@@ -56,10 +55,12 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
||||
|
||||
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.UserId);
|
||||
|
||||
eb.Property(static t => t.Token);
|
||||
eb.HasIndex(static t => t.Token).IsUnique();
|
||||
|
||||
@@ -4,13 +4,20 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
||||
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
services.ConfigureOptions<JwtOptionsSetup>();
|
||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||
|
||||
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
|
||||
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
|
||||
|
||||
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
||||
services.AddDbContext<AppDbContext>();
|
||||
services.AddHostedService<MigrationService>();
|
||||
|
||||
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")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -2,13 +2,19 @@ global using System.IdentityModel.Tokens.Jwt;
|
||||
global using System.Security.Claims;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Data;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Authorization;
|
||||
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.Diagnostics;
|
||||
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;
|
||||
|
||||
public class TestApi : WebApplicationFactory<Program>
|
||||
@@ -14,22 +10,12 @@ public class TestApi : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureTestServices(static c =>
|
||||
{
|
||||
var dbOpts = Options.Create(new AppDbContextOptions()
|
||||
c.AddSingleton(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);
|
||||
c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ namespace FiscalOS.API.Tests.Integration;
|
||||
|
||||
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
|
||||
|
||||
[Theory]
|
||||
[ClassData<LoginValidationTestCases>]
|
||||
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
||||
@@ -12,13 +14,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
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);
|
||||
|
||||
var problem = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
|
||||
|
||||
problem!.Errors.Should().BeEquivalentTo(tc.ExpectedErrors);
|
||||
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -30,9 +28,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
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]
|
||||
@@ -53,13 +51,13 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
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]
|
||||
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtToken()
|
||||
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
|
||||
{
|
||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
{
|
||||
@@ -76,18 +74,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
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);
|
||||
|
||||
|
||||
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();
|
||||
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.Http.Headers;
|
||||
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.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Data;
|
||||
|
||||
global using Microsoft.AspNetCore.Hosting;
|
||||
@@ -14,5 +22,6 @@ global using Microsoft.EntityFrameworkCore;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
global using Xunit.Sdk;
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace FiscalOS.Infra.Tests.Assertions;
|
||||
|
||||
internal static class JwtTokenAssertionExtensions
|
||||
internal static class JwtTokenAssertions
|
||||
{
|
||||
public static AndConstraint<string> HaveClaim(
|
||||
this StringAssertions assertions,
|
||||
|
||||
Reference in New Issue
Block a user