165 lines
8.4 KiB
C#
165 lines
8.4 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using MAX.Framework.DependencyInjection;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using System.Security.Claims;
|
|
using MAX.Framework.Security;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Store.Users.Tests;
|
|
|
|
public sealed class PersistentSessionTests
|
|
{
|
|
private sealed class Clock : TimeProvider
|
|
{
|
|
public DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
}
|
|
private static IFreeSql Open(string path)
|
|
{
|
|
var db = new FreeSql.FreeSqlBuilder()
|
|
.UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
|
|
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
|
|
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false").Build();
|
|
db.CodeFirst.ConfigEntity<UserToken>(e => e.Name("tokens"));
|
|
db.CodeFirst.ConfigEntity<TokenAccount>(e => e.Name("users"));
|
|
db.CodeFirst.SyncStructure<UserToken>();
|
|
db.Ado.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, user_name TEXT, nick_name TEXT)");
|
|
db.Ado.ExecuteNonQuery("INSERT OR IGNORE INTO users VALUES (1, 'same', 'Nickname')");
|
|
return db;
|
|
}
|
|
private static TokenResult Issue(TokenService service, string? device = null) =>
|
|
service.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10", device, "Device");
|
|
|
|
[Fact]
|
|
public async Task BearerHandler_EnforcesDatabaseTokensAndReturns401And403()
|
|
{
|
|
using var db = Open(":memory:");
|
|
var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
builder.Services.AddSingleton<IFreeSql>(db);
|
|
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
|
builder.Services.AddAuthorization(
|
|
options => options.AddPolicy("deny", policy => policy.RequireClaim("missing")));
|
|
await using var app = builder.Build();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapGet("/private", () => "ok").RequireAuthorization();
|
|
app.MapGet("/forbidden", () => "ok").RequireAuthorization("deny");
|
|
await app.StartAsync();
|
|
using var client = app.GetTestClient();
|
|
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
|
|
var service = app.Services.GetRequiredService<ITokenService>();
|
|
var token = Issue((TokenService)service);
|
|
client.DefaultRequestHeaders.Authorization = new("Bearer", token.Token);
|
|
Assert.Equal("ok", await client.GetStringAsync("/private"));
|
|
Assert.Equal(System.Net.HttpStatusCode.Forbidden, (await client.GetAsync("/forbidden")).StatusCode);
|
|
var principal = await service.ValidateTokenAsync(token.Token);
|
|
var store = app.Services.GetRequiredService<IOnlineUserStore>();
|
|
store.SignOutSession("same", principal!.FindFirst(JwtClaimTypes.Jti)!.Value);
|
|
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TokensSurviveRestart_AndLogoutInvalidatesWarmCache()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"tokens-{Guid.NewGuid():N}.db");
|
|
var clock = new Clock();
|
|
var options = Options.Create(new TokenAuthOptions());
|
|
try
|
|
{
|
|
TokenResult first, second;
|
|
using (var db = Open(path))
|
|
using (var cache = new MemoryCache(new MemoryCacheOptions()))
|
|
{
|
|
var service = new TokenService(db, options, cache, clock);
|
|
first = Issue(service); second = Issue(service);
|
|
Assert.Equal(32, Convert.FromBase64String(first.Token).Length);
|
|
Assert.NotEqual(first.Token, second.Token);
|
|
Assert.All(db.Select<UserToken>().ToList(), x => Assert.Equal(64, x.TokenHash.Length));
|
|
}
|
|
using (var db = Open(path))
|
|
using (var cache = new MemoryCache(new MemoryCacheOptions()))
|
|
{
|
|
var service = new TokenService(db, options, cache, clock);
|
|
var store = new OnlineUserStore(db, service);
|
|
var principal = await service.ValidateTokenAsync(first.Token);
|
|
Assert.Equal("Nickname", principal!.FindFirst(JwtClaimTypes.NickName)!.Value);
|
|
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
|
|
var id = principal.FindFirst(JwtClaimTypes.Jti)!.Value;
|
|
store.SignOutSession("other", id);
|
|
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
|
|
store.SignOutSession("same", id);
|
|
Assert.Null(await service.ValidateTokenAsync(first.Token));
|
|
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
|
|
Assert.Single(store.GetOnlineUsers());
|
|
store.SignOut("same");
|
|
Assert.Null(await service.ValidateTokenAsync(second.Token));
|
|
Assert.All(db.Select<UserToken>().ToList(), t => Assert.True(t.Revoked));
|
|
}
|
|
}
|
|
finally { File.Delete(path); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SlidingRenewal_IsThrottled_AndCappedByAbsoluteExpiry()
|
|
{
|
|
using var db = Open(":memory:");
|
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
|
var clock = new Clock();
|
|
var service = new TokenService(db, Options.Create(new TokenAuthOptions { IdleDays = 2, AbsoluteMaxDays = 3 }), cache, clock);
|
|
var token = Issue(service);
|
|
var initial = db.Select<UserToken>().First();
|
|
clock.Now = clock.Now.AddMinutes(9);
|
|
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
|
Assert.Equal(initial.ExpiresAt, db.Select<UserToken>().First().ExpiresAt);
|
|
clock.Now = clock.Now.AddMinutes(1);
|
|
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
|
Assert.Equal(clock.Now.UtcDateTime.AddDays(2), db.Select<UserToken>().First().ExpiresAt);
|
|
clock.Now = clock.Now.AddDays(1);
|
|
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
|
Assert.Equal(initial.CreatedAt.AddDays(3), db.Select<UserToken>().First().ExpiresAt);
|
|
clock.Now = new DateTimeOffset(initial.CreatedAt.AddDays(3), TimeSpan.Zero);
|
|
Assert.Null(await service.ValidateTokenAsync(token.Token));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 3)]
|
|
[InlineData(1, 1)]
|
|
[InlineData(2, 2)]
|
|
public async Task DeviceLimit_EvictsOldestAndClearsCache(int limit, int expected)
|
|
{
|
|
using var db = Open(":memory:");
|
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
|
var service = new TokenService(db, Options.Create(new TokenAuthOptions { MaxDevicesPerUser = limit }), cache, new Clock());
|
|
var first = Issue(service, "a");
|
|
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
|
|
Issue(service, "b"); var last = Issue(service, "c");
|
|
Assert.Equal(expected, db.Select<UserToken>().Where(t => !t.Revoked).Count());
|
|
Assert.NotNull(await service.ValidateTokenAsync(last.Token));
|
|
Assert.Equal(limit == 0, await service.ValidateTokenAsync(first.Token) != null);
|
|
var replacement = Issue(service, "c");
|
|
Assert.Null(await service.ValidateTokenAsync(last.Token));
|
|
Assert.NotNull(await service.ValidateTokenAsync(replacement.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry()
|
|
{
|
|
using var db = Open(":memory:");
|
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
|
var clock = new Clock();
|
|
var service = new TokenService(db, Options.Create(new TokenAuthOptions { CacheSeconds = 0 }), cache, clock);
|
|
var token = Issue(service);
|
|
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
|
db.Update<UserToken>().Set(t => t.Revoked, true).Where(t => t.Id > 0).ExecuteAffrows();
|
|
Assert.Null(await service.ValidateTokenAsync(token.Token));
|
|
var second = Issue(service);
|
|
clock.Now = clock.Now.AddDays(7);
|
|
Assert.Null(await service.ValidateTokenAsync(second.Token));
|
|
Assert.Null(await service.ValidateTokenAsync("malformed"));
|
|
}
|
|
}
|