mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Tokens now carry a keyed hash of the account's security stamp, and every authenticated request compares it with the stored stamp (cached for 60 s, evicted in-process on change). A password reset or change, a deactivation and a deletion all rotate or remove the stamp, so tokens issued before them get 401. Tokens without the claim get 401 too.
268 lines
11 KiB
C#
268 lines
11 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Api.SeaHavenIndustries.Controllers;
|
|
using Api.SeaHavenIndustries.Infrastructure;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Hosting.Server;
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using SeaHaven.DataServices.DependencyInjection;
|
|
using SeaHaven.Services.DependencyInjection;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
/// <summary>
|
|
/// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite
|
|
/// file database, with Identity and bearer authentication registered exactly as the
|
|
/// API host registers them. Email goes to an in-memory sender and every log line is
|
|
/// captured.
|
|
/// </summary>
|
|
internal sealed class SessionTestHost : IAsyncDisposable
|
|
{
|
|
public static readonly string JwtSecret = new('s', 64);
|
|
public const string Issuer = "issuer";
|
|
public const string Audience = "audience";
|
|
public const string Password = "Harbor-Light-42!";
|
|
|
|
private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
|
|
|
|
private readonly WebApplication _app;
|
|
private readonly string _databasePath;
|
|
|
|
private SessionTestHost(WebApplication app, string databasePath, HttpClient client)
|
|
{
|
|
_app = app;
|
|
_databasePath = databasePath;
|
|
Client = client;
|
|
}
|
|
|
|
public HttpClient Client { get; }
|
|
public CapturingEmailSender Sent { get; private set; } = null!;
|
|
public CapturingLoggerProvider Logged { get; private set; } = null!;
|
|
|
|
public static async Task<SessionTestHost> StartAsync()
|
|
{
|
|
var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
|
|
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
|
|
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
|
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["JWT:Secret"] = JwtSecret,
|
|
["JWT:ValidIssuer"] = Issuer,
|
|
["JWT:ValidAudience"] = Audience
|
|
});
|
|
|
|
var sent = new CapturingEmailSender();
|
|
var logged = new CapturingLoggerProvider();
|
|
builder.Logging.ClearProviders();
|
|
builder.Logging.SetMinimumLevel(LogLevel.Trace);
|
|
builder.Logging.AddProvider(logged);
|
|
|
|
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
|
|
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
|
new SqliteSessionDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
|
builder.Services.AddSeaHavenIdentity();
|
|
builder.Services.AddSingleton<IEmailSender>(sent);
|
|
builder.Services.AddDataServices();
|
|
builder.Services.AddBusinessServices(builder.Configuration);
|
|
builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration);
|
|
builder.Services.AddControllers()
|
|
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
|
.ConfigureApplicationPartManager(manager =>
|
|
{
|
|
manager.FeatureProviders.Clear();
|
|
manager.FeatureProviders.Add(new SessionControllers());
|
|
});
|
|
|
|
var app = builder.Build();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapControllers();
|
|
|
|
await using (var scope = app.Services.CreateAsyncScope())
|
|
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
|
|
|
|
await app.StartAsync();
|
|
var address = app.Services.GetRequiredService<IServer>().Features
|
|
.Get<IServerAddressesFeature>()!.Addresses.Single();
|
|
|
|
var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) });
|
|
host.Sent = sent;
|
|
host.Logged = logged;
|
|
return host;
|
|
}
|
|
|
|
public async Task<ApplicationUser> AddUserAsync(string email, string? role = null)
|
|
{
|
|
await using var scope = _app.Services.CreateAsyncScope();
|
|
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var user = new ApplicationUser
|
|
{
|
|
UserName = email,
|
|
Email = email,
|
|
FirstName = "Sam",
|
|
LastName = "Lee",
|
|
EmailConfirmed = true,
|
|
UniqueName = "Active",
|
|
CreatedDate = DateTime.UtcNow
|
|
};
|
|
var created = await users.CreateAsync(user, Password);
|
|
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description)));
|
|
|
|
if (role != null)
|
|
{
|
|
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
|
if (!await roles.RoleExistsAsync(role))
|
|
await roles.CreateAsync(new IdentityRole(role));
|
|
await users.AddToRoleAsync(user, role);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
public async Task<string> SignInAsync(string email, string password = Password)
|
|
{
|
|
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
|
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
return payload.RootElement.GetProperty("token").GetString()!;
|
|
}
|
|
|
|
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token, object? json = null)
|
|
{
|
|
var request = new HttpRequestMessage(method, path);
|
|
if (token != null)
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
if (json != null)
|
|
request.Content = JsonContent.Create(json);
|
|
return Client.SendAsync(request);
|
|
}
|
|
|
|
/// <summary>An authorized read that only succeeds for a signed-in, accepted session.</summary>
|
|
public Task<HttpResponseMessage> ProfileAsync(string? token) =>
|
|
SendAsync(HttpMethod.Get, "api/User/UserProfile", token);
|
|
|
|
/// <summary>Runs Forgot Password end to end: request a code, read it from the email, reset.</summary>
|
|
public async Task ResetPasswordAsync(string email, string newPassword)
|
|
{
|
|
var before = Sent.Messages.Count;
|
|
using (var requested = await SendAsync(
|
|
HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email }))
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode);
|
|
|
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
|
SentEmail? message;
|
|
while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null)
|
|
{
|
|
if (DateTime.UtcNow > deadline)
|
|
throw new TimeoutException("No password reset email was sent.");
|
|
await Task.Delay(10);
|
|
}
|
|
|
|
var code = ResetCode.Match(message.Body).Groups[1].Value;
|
|
using var reset = await SendAsync(
|
|
HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword });
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode);
|
|
}
|
|
|
|
public async Task<string?> StoredSecurityStampAsync(string userId)
|
|
{
|
|
await using var scope = _app.Services.CreateAsyncScope();
|
|
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
|
.Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-signs <paramref name="token"/> with the host's real signing key after letting
|
|
/// <paramref name="edit"/> change its claims, so only the claims differ from a token
|
|
/// the API issued.
|
|
/// </summary>
|
|
public static string Resign(string token, Action<List<System.Security.Claims.Claim>> edit)
|
|
{
|
|
var original = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
|
var claims = original.Claims
|
|
.Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat))
|
|
.ToList();
|
|
edit(claims);
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
|
|
var resigned = new JwtSecurityToken(
|
|
issuer: Issuer,
|
|
audience: Audience,
|
|
claims: claims,
|
|
expires: original.ValidTo,
|
|
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
|
return new JwtSecurityTokenHandler().WriteToken(resigned);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
Client.Dispose();
|
|
await _app.StopAsync();
|
|
await _app.DisposeAsync();
|
|
SqliteConnection.ClearAllPools();
|
|
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
|
|
{
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
private sealed class SessionControllers : ControllerFeatureProvider
|
|
{
|
|
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
|
|
typeInfo.AsType() == typeof(AuthenticationController)
|
|
|| typeInfo.AsType() == typeof(UserController)
|
|
|| typeInfo.AsType() == typeof(TeamMemberController);
|
|
}
|
|
|
|
private sealed class SqliteSessionDbContext : ApplicationDbContext
|
|
{
|
|
public SqliteSessionDbContext(DbContextOptions<ApplicationDbContext> options)
|
|
: base(options)
|
|
{
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder builder)
|
|
{
|
|
base.OnModelCreating(builder);
|
|
|
|
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
|
{
|
|
// SQL Server filter syntax does not carry over; a filtered unique index
|
|
// without its filter would wrongly reject a second user.
|
|
if (index.GetFilter() is not null)
|
|
{
|
|
index.SetFilter(null);
|
|
index.IsUnique = false;
|
|
}
|
|
}
|
|
|
|
foreach (var property in builder.Model.GetEntityTypes()
|
|
.SelectMany(entity => entity.GetProperties())
|
|
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
|
|
{
|
|
property.ValueGenerated = ValueGenerated.Never;
|
|
property.IsConcurrencyToken = false;
|
|
}
|
|
}
|
|
}
|
|
}
|