445 lines
20 KiB
C#
445 lines
20 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class AuthSessionLifecycleTests
|
|
{
|
|
[Fact]
|
|
public async Task Active_cache_falls_back_to_postgres_when_redis_is_unavailable()
|
|
{
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
|
{
|
|
["ConnectionStrings:Redis"] =
|
|
"localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
|
|
["Security:AuthorizationCache:Mode"] = "Active"
|
|
});
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
using var scope = factory.CreateSystemScope("Validate PostgreSQL authorization fallback");
|
|
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Active_redis_cache_rejects_disabled_membership_on_the_next_validation()
|
|
{
|
|
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
|
if (string.IsNullOrWhiteSpace(redis)) return;
|
|
|
|
var commandRecorder = new RecordingDbCommandInterceptor();
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
|
{
|
|
["ConnectionStrings:Redis"] = redis,
|
|
["Security:AuthorizationCache:Mode"] = "Active"
|
|
}, dbCommandInterceptor: commandRecorder);
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
|
|
using (var scope = factory.CreateSystemScope("Warm Redis access security state"))
|
|
{
|
|
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
var cached = await scope.ServiceProvider.GetRequiredService<IAccessSecurityCache>()
|
|
.GetAsync(
|
|
new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
Assert.True(cached?.Complete);
|
|
}
|
|
|
|
commandRecorder.Reset();
|
|
using (var scope = factory.CreateSystemScope("Validate hot Redis access security state"))
|
|
{
|
|
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
}
|
|
|
|
Assert.Empty(commandRecorder.Snapshot());
|
|
|
|
using (var scope = factory.CreateSystemScope("Disable membership and invalidate Redis"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var membership = await dbContext.TenantMemberships.SingleAsync(item =>
|
|
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
|
|
membership.Status = MembershipStatus.Disabled;
|
|
await dbContext.SaveChangesAsync();
|
|
await scope.ServiceProvider.GetRequiredService<IAuthorizationStateInvalidator>()
|
|
.InvalidateMembershipAsync(seed.TenantId, seed.UserId);
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Validate disabled cached membership"))
|
|
{
|
|
Assert.Null(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Authorization_version_bypasses_stale_local_permission_snapshot_after_revocation()
|
|
{
|
|
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
|
if (string.IsNullOrWhiteSpace(redis)) return;
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
|
{
|
|
["ConnectionStrings:Redis"] = redis,
|
|
["Security:AuthorizationCache:Mode"] = "Active"
|
|
});
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var roleId = Guid.NewGuid();
|
|
const string permissionCode = "tenant:auth-cache:manage";
|
|
await factory.SeedAsync(new BackendPermission
|
|
{
|
|
Code = permissionCode,
|
|
Name = "Authorization cache test permission",
|
|
Area = BackendPermissionArea.Tenant,
|
|
PermissionModuleCode =
|
|
PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage)
|
|
});
|
|
using (var scope = factory.CreateSystemScope("Seed versioned tenant permission"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
|
{
|
|
Realm = AuthRealm.Tenant,
|
|
TenantId = seed.TenantId
|
|
});
|
|
await dbContext.SaveChangesAsync();
|
|
dbContext.TenantBackendRoles.Add(new TenantBackendRole
|
|
{
|
|
Id = roleId,
|
|
TenantId = seed.TenantId,
|
|
Code = "versioned-cache-role",
|
|
Name = "Versioned cache role"
|
|
});
|
|
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
|
|
{
|
|
TenantId = seed.TenantId,
|
|
RoleId = roleId,
|
|
PermissionCode = permissionCode
|
|
});
|
|
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
RoleId = roleId
|
|
});
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
AuthSessionValidationResult validated;
|
|
using (var scope = factory.CreateSystemScope("Warm session state for permission snapshot"))
|
|
{
|
|
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
|
|
.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
}
|
|
|
|
Assert.True((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
|
|
|
|
using (var scope = factory.CreateSystemScope("Revoke permission and publish durable version"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item =>
|
|
item.TenantId == seed.TenantId && item.RoleId == roleId && item.PermissionCode == permissionCode);
|
|
dbContext.Remove(binding);
|
|
await dbContext.SaveChangesAsync();
|
|
await scope.ServiceProvider.GetRequiredService<IAuthorizationCacheInvalidationProcessor>()
|
|
.ProcessPendingAsync();
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Reload session authorization version"))
|
|
{
|
|
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
|
|
.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
}
|
|
|
|
Assert.False((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
|
|
}
|
|
|
|
private static async Task<CurrentAccessSnapshot> LoadAccessAsync(
|
|
ApiTestFactory factory, SessionSeed seed, AuthSessionValidationResult validated)
|
|
{
|
|
using var scope = factory.Services.CreateScope();
|
|
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
|
.Initialize(seed.TenantId, null, TenantResolutionSource.Jwt);
|
|
((CurrentUser)scope.ServiceProvider.GetRequiredService<ICurrentUser>()).Load(new ClaimsPrincipal(
|
|
new ClaimsIdentity([new Claim(TikuClaimTypes.UserId, seed.UserId.ToString())], "integration")));
|
|
scope.ServiceProvider.GetRequiredService<IRequestSecurityState>().SetValidatedSession(validated);
|
|
return await scope.ServiceProvider.GetRequiredService<ICurrentAccessContext>().GetAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var original = await IssueAsync(factory, seed);
|
|
|
|
AuthTokenPair rotated;
|
|
using (var scope = factory.CreateSystemScope("Rotate refresh token"))
|
|
{
|
|
rotated = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.RotateAsync(original.RefreshToken, "127.0.0.1", "integration-test");
|
|
}
|
|
|
|
Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator));
|
|
Assert.True(TryLocate(factory, rotated.RefreshToken, out var rotatedLocator));
|
|
|
|
using (var scope = factory.CreateSystemScope("Verify rotated session lineage"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var sessions = await dbContext.AuthSessions
|
|
.Where(session => session.Id == originalLocator.SessionId || session.Id == rotatedLocator.SessionId)
|
|
.OrderBy(session => session.ParentSessionId == null ? 0 : 1)
|
|
.ToListAsync();
|
|
|
|
Assert.Equal(2, sessions.Count);
|
|
Assert.Equal(originalLocator.SessionId, sessions[0].Id);
|
|
Assert.Equal(rotatedLocator.SessionId, sessions[0].ReplacedBySessionId);
|
|
Assert.Equal("rotated", sessions[0].RevokedReason);
|
|
Assert.Equal(originalLocator.SessionId, sessions[1].ParentSessionId);
|
|
Assert.Equal(sessions[0].TokenFamilyId, sessions[1].TokenFamilyId);
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Replay rotated refresh token"))
|
|
{
|
|
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
|
scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.RotateAsync(original.RefreshToken, null, null));
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Verify refresh family revocation"))
|
|
{
|
|
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
|
var validation = await store.ValidateAccessSessionAsync(
|
|
rotatedLocator.SessionId,
|
|
seed.UserId,
|
|
AuthRealm.Tenant,
|
|
seed.TenantId);
|
|
Assert.Null(validation);
|
|
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var family = await dbContext.AuthSessions
|
|
.Where(session => session.TokenFamilyId == originalLocator.SessionId)
|
|
.ToListAsync();
|
|
Assert.All(family, session => Assert.NotNull(session.RevokedAt));
|
|
Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_refresh_allows_only_one_rotation_and_revokes_the_replayed_family()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var original = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator));
|
|
|
|
using var firstScope = factory.CreateSystemScope("First concurrent refresh");
|
|
using var secondScope = factory.CreateSystemScope("Second concurrent refresh");
|
|
var first = TryRotateAsync(
|
|
firstScope.ServiceProvider.GetRequiredService<IAuthSessionStore>(),
|
|
original.RefreshToken);
|
|
var second = TryRotateAsync(
|
|
secondScope.ServiceProvider.GetRequiredService<IAuthSessionStore>(),
|
|
original.RefreshToken);
|
|
var results = await Task.WhenAll(first, second);
|
|
|
|
Assert.Single(results, result => result is not null);
|
|
Assert.Single(results, result => result is null);
|
|
|
|
using var verificationScope = factory.CreateSystemScope("Verify concurrent refresh family");
|
|
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var family = await dbContext.AuthSessions
|
|
.Where(session => session.TokenFamilyId == originalLocator.SessionId)
|
|
.ToListAsync();
|
|
Assert.Equal(2, family.Count);
|
|
Assert.All(family, session => Assert.NotNull(session.RevokedAt));
|
|
Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Access_session_fails_immediately_after_membership_is_disabled()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
|
|
using (var scope = factory.CreateSystemScope("Disable tenant membership"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var membership = await dbContext.TenantMemberships.SingleAsync(item =>
|
|
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
|
|
membership.Status = MembershipStatus.Disabled;
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Validate disabled membership session"))
|
|
{
|
|
var validation = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId);
|
|
Assert.Null(validation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Access_session_fails_immediately_after_security_stamp_changes()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
|
|
using (var scope = factory.CreateSystemScope("Change user security stamp"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var user = await dbContext.Users.SingleAsync(item => item.Id == seed.UserId);
|
|
user.SecurityStamp = Guid.NewGuid().ToString("N");
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Validate stale security stamp session"))
|
|
{
|
|
var validation = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId);
|
|
Assert.Null(validation);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_session_remains_valid_after_a_backend_permission_is_revoked()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedActiveMemberAsync(factory);
|
|
var role = new TenantBackendRole
|
|
{
|
|
TenantId = seed.TenantId,
|
|
Code = "session-test-admin",
|
|
Name = "Session test administrator"
|
|
};
|
|
const string permissionCode = "tenant:session-test:manage";
|
|
await factory.SeedAsync(
|
|
new BackendPermission
|
|
{
|
|
Code = permissionCode,
|
|
Name = permissionCode,
|
|
Area = BackendPermissionArea.Tenant,
|
|
PermissionModuleCode = "tenant_dashboard"
|
|
},
|
|
role,
|
|
new TenantBackendRolePermission
|
|
{
|
|
TenantId = seed.TenantId,
|
|
RoleId = role.Id,
|
|
PermissionCode = permissionCode
|
|
},
|
|
new TenantBackendUserRole
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
RoleId = role.Id
|
|
});
|
|
var tokens = await IssueAsync(factory, seed);
|
|
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
|
|
|
|
using (var scope = factory.CreateSystemScope("Revoke final backend permission"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item =>
|
|
item.TenantId == seed.TenantId && item.RoleId == role.Id);
|
|
dbContext.TenantBackendRolePermissions.Remove(binding);
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Validate tenant session"))
|
|
{
|
|
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
|
Assert.NotNull(await store.ValidateAccessSessionAsync(
|
|
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
|
Assert.NotNull(await store.RotateAsync(tokens.RefreshToken, null, null));
|
|
}
|
|
}
|
|
|
|
private static bool TryLocate(
|
|
ApiTestFactory factory,
|
|
string refreshToken,
|
|
out RefreshTokenLocator locator)
|
|
{
|
|
using var scope = factory.CreateSystemScope("Parse refresh token locator");
|
|
return scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
|
.TryParseRefreshToken(refreshToken, out locator);
|
|
}
|
|
|
|
private static async Task<AuthTokenPair?> TryRotateAsync(IAuthSessionStore store, string refreshToken)
|
|
{
|
|
try
|
|
{
|
|
return await store.RotateAsync(refreshToken, null, null);
|
|
}
|
|
catch (SessionRevokedException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task<AuthTokenPair> IssueAsync(
|
|
ApiTestFactory factory,
|
|
SessionSeed seed)
|
|
{
|
|
using var scope = factory.CreateSystemScope("Issue authentication session");
|
|
return await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>().IssueAsync(
|
|
new AuthSessionIssueRequest(
|
|
seed.UserId,
|
|
seed.Phone,
|
|
null,
|
|
seed.SecurityStamp,
|
|
AuthRealm.Tenant,
|
|
seed.TenantId,
|
|
"integration-test",
|
|
"127.0.0.1",
|
|
"integration-test"));
|
|
}
|
|
|
|
private static async Task<SessionSeed> SeedActiveMemberAsync(ApiTestFactory factory)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var user = new User
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Phone = $"13{Random.Shared.Next(100_000_000, 1_000_000_000)}",
|
|
Name = "Session lifecycle user"
|
|
};
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Session lifecycle tenant",
|
|
Status = TenantStatus.Active
|
|
},
|
|
user,
|
|
new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = user.Id,
|
|
Role = TenantRole.Student,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
|
|
return new SessionSeed(tenantId, user.Id, user.Phone, user.SecurityStamp!);
|
|
}
|
|
|
|
private sealed record SessionSeed(Guid TenantId, Guid UserId, string Phone, string SecurityStamp);
|
|
} |