mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
A token carries the user's roles and account, so a demoted admin kept admin claims until the token expired. The team member update and the admin user edit now rotate the security stamp and evict the cached value when the role, account or user name changes. Permission overrides are read per request and are not in the token.
276 lines
12 KiB
C#
276 lines
12 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 EnsureRoleAsync(string role)
|
|
{
|
|
await using var scope = _app.Services.CreateAsyncScope();
|
|
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
|
if (!await roles.RoleExistsAsync(role))
|
|
await roles.CreateAsync(new IdentityRole(role));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|