Files
StoreWeb/Tests/Store.Security.Tests/PersistentSessionTests.cs
T
2026-11-06 19:25:36 +08:00

194 lines
10 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 Cleanup_RemovesRevokedAndExpiredTokens_AndPreservesActiveTokens()
{
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()), cache, clock);
var active = Issue(service);
Assert.NotNull(await service.ValidateTokenAsync(active.Token));
var now = DateTime.SpecifyKind(clock.Now.UtcDateTime, DateTimeKind.Unspecified);
// 覆盖撤销、闲置到期、绝对到期的等值边界,以及尚差一秒到期的有效记录。
db.Insert(new[]
{
new UserToken { UserName = "same", TokenHash = "revoked", CreatedAt = now, ExpiresAt = now.AddDays(7), Revoked = true },
new UserToken { UserName = "same", TokenHash = "idle", CreatedAt = now.AddDays(-7), ExpiresAt = now },
new UserToken { UserName = "same", TokenHash = "absolute", CreatedAt = now.AddDays(-30), ExpiresAt = now.AddDays(1) },
new UserToken { UserName = "same", TokenHash = "near-expiry", CreatedAt = now.AddDays(-30).AddSeconds(1), ExpiresAt = now.AddSeconds(1) }
}).ExecuteAffrows();
Assert.Equal(3, service.CleanupExpiredTokens());
Assert.Equal(2, db.Select<UserToken>().Count());
Assert.True(db.Select<UserToken>().Any(t => t.TokenHash == "near-expiry"));
Assert.Equal(0, service.CleanupExpiredTokens());
Assert.NotNull(await service.ValidateTokenAsync(active.Token));
clock.Now = clock.Now.AddSeconds(1);
Assert.Equal(1, service.CleanupExpiredTokens());
Assert.Single(db.Select<UserToken>().ToList());
}
[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"));
}
}