forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Growth;
|
||||
@@ -11,6 +13,7 @@ using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Api;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -25,12 +28,32 @@ public sealed class ApiTestFactory(
|
||||
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
||||
IPaymentProviderGateway? paymentProviderGateway = null,
|
||||
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
|
||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<ApiProgramMarker>
|
||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
|
||||
ISmsProvider? smsProvider = null,
|
||||
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
|
||||
{
|
||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
|
||||
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
|
||||
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
|
||||
};
|
||||
if (configurationOverrides is not null)
|
||||
{
|
||||
foreach (var pair in configurationOverrides)
|
||||
{
|
||||
values[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
configuration.AddInMemoryCollection(values);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
foreach (var descriptor in services
|
||||
@@ -53,6 +76,8 @@ public sealed class ApiTestFactory(
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
services.RemoveAll<IJwtKeyRing>();
|
||||
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
||||
|
||||
if (wechatOAuthClient is not null)
|
||||
{
|
||||
@@ -85,6 +110,12 @@ public sealed class ApiTestFactory(
|
||||
services.RemoveAll<IDomainGatewayProvisioner>();
|
||||
services.AddSingleton(domainGatewayProvisioner);
|
||||
}
|
||||
|
||||
if (smsProvider is not null)
|
||||
{
|
||||
services.RemoveAll<ISmsProvider>();
|
||||
services.AddSingleton(smsProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +127,91 @@ public sealed class ApiTestFactory(
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.AddRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var backendMembers = entities
|
||||
.OfType<TenantMembership>()
|
||||
.Where(membership =>
|
||||
membership.Status == MembershipStatus.Active &&
|
||||
membership.Role is TenantRole.TenantOwner or TenantRole.TenantAdmin)
|
||||
.Select(membership => (membership.TenantId, membership.UserId))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (backendMembers.Length > 0)
|
||||
{
|
||||
await EnsureTenantBackendAccessAsync(dbContext, backendMembers);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureTenantBackendAccessAsync(
|
||||
TikuDbContext dbContext,
|
||||
IEnumerable<(Guid TenantId, Guid UserId)> members)
|
||||
{
|
||||
var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray();
|
||||
var existingPermissionCodes = await dbContext.BackendPermissions
|
||||
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToListAsync();
|
||||
foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal))
|
||||
{
|
||||
dbContext.BackendPermissions.Add(new BackendPermission
|
||||
{
|
||||
Code = permissionCode,
|
||||
Name = permissionCode,
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = permissionCode.Split(':')[1],
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var tenantGroup in members.GroupBy(member => member.TenantId))
|
||||
{
|
||||
var tenantId = tenantGroup.Key;
|
||||
var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(entity =>
|
||||
entity.TenantId == tenantId && entity.Code == "integration_test_admin");
|
||||
if (role is null)
|
||||
{
|
||||
role = new TenantBackendRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Code = "integration_test_admin",
|
||||
Name = "Integration Test Administrator",
|
||||
Status = BackendRoleStatus.Active,
|
||||
IsSystem = true,
|
||||
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
|
||||
};
|
||||
dbContext.TenantBackendRoles.Add(role);
|
||||
}
|
||||
|
||||
var assignedPermissionCodes = await dbContext.TenantBackendRolePermissions
|
||||
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
|
||||
.Select(entity => entity.PermissionCode)
|
||||
.ToListAsync();
|
||||
foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal))
|
||||
{
|
||||
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = role.Id,
|
||||
PermissionCode = permissionCode
|
||||
});
|
||||
}
|
||||
|
||||
var assignedUserIds = await dbContext.TenantBackendUserRoles
|
||||
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
|
||||
.Select(entity => entity.UserId)
|
||||
.ToListAsync();
|
||||
foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId)))
|
||||
{
|
||||
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = member.UserId,
|
||||
RoleId = role.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
|
||||
@@ -151,39 +267,51 @@ public sealed class ApiTestFactory(
|
||||
public async Task<Guid> SeedActiveSessionAsync(
|
||||
Guid userId,
|
||||
Guid? tenantId = null,
|
||||
string tokenHash = "integration-test-token-hash")
|
||||
string tokenHash = "integration-test-token-hash",
|
||||
bool includeMembership = false)
|
||||
{
|
||||
var resolvedTenantId = tenantId ?? Guid.NewGuid();
|
||||
await SeedAsync(
|
||||
var user = new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = "13800000000"
|
||||
};
|
||||
var session = new AuthSession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantId = resolvedTenantId,
|
||||
UserId = userId,
|
||||
TokenFamilyId = Guid.NewGuid(),
|
||||
TokenHash = tokenHash,
|
||||
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||
Provider = "test",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant
|
||||
{
|
||||
Id = resolvedTenantId,
|
||||
Slug = resolvedTenantId.ToString("N"),
|
||||
Name = "Test Tenant"
|
||||
},
|
||||
new User
|
||||
user,
|
||||
session
|
||||
};
|
||||
if (includeMembership)
|
||||
{
|
||||
entities.Add(new TenantMembership
|
||||
{
|
||||
Id = userId,
|
||||
Phone = "13800000000"
|
||||
},
|
||||
new AuthSession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = resolvedTenantId,
|
||||
UserId = userId,
|
||||
TokenHash = tokenHash,
|
||||
Provider = "test",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
return await dbContext.AuthSessions
|
||||
.Where(session => session.UserId == userId)
|
||||
.Select(session => session.Id)
|
||||
.SingleAsync();
|
||||
await SeedAsync([.. entities]);
|
||||
return session.Id;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
Reference in New Issue
Block a user