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)
|
||||
|
||||
@@ -214,8 +214,6 @@ public sealed class AssetAccessEndpointTests
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13800000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, tenantId.ToString("N")),
|
||||
new User
|
||||
@@ -223,21 +221,13 @@ public sealed class AssetAccessEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Test User"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -247,20 +237,7 @@ public sealed class AssetAccessEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -269,13 +246,6 @@ public sealed class AssetAccessEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||
{
|
||||
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
|
||||
|
||||
@@ -255,8 +255,6 @@ public sealed class AssetManagementEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13900000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -271,21 +269,13 @@ public sealed class AssetManagementEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -295,20 +285,7 @@ public sealed class AssetManagementEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -317,13 +294,6 @@ public sealed class AssetManagementEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||
{
|
||||
public long? MetadataSizeBytes { get; init; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
@@ -15,6 +16,59 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm()
|
||||
{
|
||||
var provider = new CapturingSmsProvider();
|
||||
await using var factory = new ApiTestFactory(smsProvider: provider);
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "SMS Tenant"
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/sms/send",
|
||||
new SendSmsCodeDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Phone = "13800000000",
|
||||
DeviceId = "sms-endpoint-device"
|
||||
});
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.DoesNotContain(provider.Code!, body, StringComparison.Ordinal);
|
||||
Assert.Matches("^[0-9]{6}$", provider.Code!);
|
||||
using (var scope = factory.CreateSystemScope("Verify SMS send endpoint"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var dimensions = await dbContext.SmsSendRateLimits
|
||||
.Select(item => item.Dimension)
|
||||
.ToArrayAsync();
|
||||
Assert.Contains(SmsRateLimitDimension.Tenant, dimensions);
|
||||
Assert.Contains(SmsRateLimitDimension.Phone, dimensions);
|
||||
Assert.Contains(SmsRateLimitDimension.Device, dimensions);
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
var platformResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/sms/send",
|
||||
new SendSmsCodeDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
Phone = "13800000000",
|
||||
DeviceId = "sms-endpoint-device"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, platformResponse.StatusCode);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
|
||||
{
|
||||
@@ -39,20 +93,11 @@ public sealed class AuthEndpointTests
|
||||
IsPrimary = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = tenantB.TenantId.ToString("N"),
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone);
|
||||
|
||||
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||
jwtRequest.Headers.Host = "a.example.test";
|
||||
jwtRequest.Headers.Authorization = new("Bearer", accessToken);
|
||||
jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken);
|
||||
var jwtResponse = await client.SendAsync(jwtRequest);
|
||||
|
||||
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
|
||||
@@ -60,8 +105,9 @@ public sealed class AuthEndpointTests
|
||||
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
|
||||
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
var spoofResponse = await client.SendAsync(spoofRequest);
|
||||
|
||||
@@ -69,6 +115,54 @@ public sealed class AuthEndpointTests
|
||||
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_authentication_artifacts_are_rejected_on_an_unconfigured_host()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth"
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}";
|
||||
var requests = new[]
|
||||
{
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password")
|
||||
{
|
||||
Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
Phone = "admin@example.com",
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
})
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout")
|
||||
{
|
||||
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
|
||||
},
|
||||
new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup")
|
||||
{
|
||||
Content = JsonContent.Create(new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = $"c1.p.-.{new string('b', 86)}"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
using (request)
|
||||
{
|
||||
request.Headers.Host = "unconfigured.example.test";
|
||||
using var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_can_access_current_user_and_tenant()
|
||||
{
|
||||
@@ -76,25 +170,11 @@ public sealed class AuthEndpointTests
|
||||
var seed = await SeedLoginUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
client.UseAccessToken(tokens);
|
||||
var meResponse = await client.GetAsync("/api/me");
|
||||
var tenantResponse = await client.GetAsync("/api/tenants/current");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
|
||||
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
@@ -108,22 +188,22 @@ public sealed class AuthEndpointTests
|
||||
var seed = await SeedLoginUserAsync(factory);
|
||||
await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456");
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/sms",
|
||||
new SmsLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Code = "123456"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
var tokens = await client.CompleteTenantAuthenticationAsync(
|
||||
loginResponse,
|
||||
seed.TenantId,
|
||||
seed.Phone);
|
||||
client.UseAccessToken(tokens);
|
||||
var meResponse = await client.GetAsync("/api/me");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
@@ -136,27 +216,16 @@ public sealed class AuthEndpointTests
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLoginUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var tokens = loginJson.RootElement.GetProperty("tokens");
|
||||
var accessToken = tokens.GetProperty("accessToken").GetString();
|
||||
var refreshToken = tokens.GetProperty("refreshToken").GetString();
|
||||
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
|
||||
var logoutResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/logout",
|
||||
new RefreshSessionDto { RefreshToken = refreshToken! });
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
||||
client.UseAccessToken(tokens);
|
||||
var meResponse = await client.GetAsync("/api/me");
|
||||
var refreshResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/refresh",
|
||||
new RefreshSessionDto { RefreshToken = refreshToken! });
|
||||
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
|
||||
@@ -206,16 +275,19 @@ public sealed class AuthEndpointTests
|
||||
EncryptionTag = protectedSecret.Tag
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/oauth/wechat-miniapp",
|
||||
new OAuthCodeDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Code = "wx-code"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("user")
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
@@ -231,6 +303,13 @@ public sealed class AuthEndpointTests
|
||||
identity.Provider == "wechat_miniapp" &&
|
||||
identity.OpenId == "mini-open-id" &&
|
||||
identity.UnionId == "union-id");
|
||||
var persistedUser = dbContext.Users.Single(user =>
|
||||
dbContext.UserIdentities.Any(identity =>
|
||||
identity.UserId == user.Id && identity.Provider == "wechat_miniapp"));
|
||||
Assert.DoesNotContain(
|
||||
"session_key",
|
||||
persistedUser.RawProfile.GetRawText(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||
@@ -239,8 +318,6 @@ public sealed class AuthEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13800000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -253,21 +330,13 @@ public sealed class AuthEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Test User"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -281,12 +350,18 @@ public sealed class AuthEndpointTests
|
||||
{
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var smsOptions = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
|
||||
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Phone = phone,
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code),
|
||||
CodeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
phone,
|
||||
SmsPurpose.Login,
|
||||
code,
|
||||
smsOptions.CodePepper),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
});
|
||||
@@ -299,13 +374,6 @@ public sealed class AuthEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
|
||||
{
|
||||
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
|
||||
@@ -346,4 +414,19 @@ public sealed class AuthEndpointTests
|
||||
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSmsProvider : ISmsProvider
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public string? Code { get; private set; }
|
||||
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SendCount++;
|
||||
Code = request.Code;
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthMfaLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enrollment_returns_recovery_codes_once_then_subsequent_login_requires_mfa()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_enrollment_required", login.RootElement.GetProperty("status").GetString());
|
||||
var challengeToken = login.RootElement.GetProperty("challengeToken").GetString()!;
|
||||
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
setupResponse.EnsureSuccessStatusCode();
|
||||
using var setup = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync());
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()!;
|
||||
|
||||
var confirmRequest = new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = AuthenticationTestClientExtensions.GenerateTotp(sharedKey)
|
||||
};
|
||||
var confirmResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
confirmResponse.EnsureSuccessStatusCode();
|
||||
using var confirmation = JsonDocument.Parse(await confirmResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal(
|
||||
"authenticated",
|
||||
confirmation.RootElement.GetProperty("authentication").GetProperty("status").GetString());
|
||||
Assert.Equal(10, confirmation.RootElement.GetProperty("recoveryCodes").GetArrayLength());
|
||||
var recoveryCode = confirmation.RootElement.GetProperty("recoveryCodes")[0].GetString()!;
|
||||
|
||||
var replayResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayResponse.StatusCode);
|
||||
|
||||
using var nextLogin = await PostPasswordLoginAsync(client, seed);
|
||||
Assert.Equal("mfa_required", nextLogin.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(nextLogin.RootElement.TryGetProperty("recoveryCodes", out _));
|
||||
|
||||
var recoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = nextLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
recoveryResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var finalLogin = await PostPasswordLoginAsync(client, seed);
|
||||
var replayedRecoveryResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = finalLogin.RootElement.GetProperty("challengeToken").GetString()!,
|
||||
Code = recoveryCode
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replayedRecoveryResponse.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify recovery code audit");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var recoveryAudits = await dbContext.AuditLogs
|
||||
.Where(item => item.Action == "auth.mfa.verified")
|
||||
.ToArrayAsync();
|
||||
var recoveryAudit = Assert.Single(recoveryAudits, item =>
|
||||
item.Details.ToString().Contains("recovery_code", StringComparison.Ordinal));
|
||||
Assert.Equal(seed.TenantId, recoveryAudit.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forced_password_change_precedes_mfa_enrollment()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedBackendUserAsync(factory, forcePasswordChange: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
using var login = await PostPasswordLoginAsync(client, seed);
|
||||
|
||||
Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(string.IsNullOrWhiteSpace(login.RootElement.GetProperty("challengeToken").GetString()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.LoginWithPassword), "login/password")]
|
||||
[InlineData(nameof(AuthController.SendSmsCode), "sms/send")]
|
||||
[InlineData(nameof(AuthController.LoginWithSms), "login/sms")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.SetupTotp), "mfa/totp/setup")]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp), "mfa/totp/confirm")]
|
||||
[InlineData(nameof(AuthController.VerifyTotp), "mfa/totp/verify")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
public void Authentication_routes_match_the_v2_contract(string actionName, string route)
|
||||
{
|
||||
var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = action?.GetCustomAttribute<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, string Phone) seed)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = PasswordTestUserExtensions.TestPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, string Phone)> SeedBackendUserAsync(
|
||||
ApiTestFactory factory,
|
||||
bool forcePasswordChange = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
const string phone = "13800000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "MFA Lifecycle Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "MFA Lifecycle User",
|
||||
ForcePasswordChange = forcePasswordChange
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return (tenantId, phone);
|
||||
}
|
||||
}
|
||||
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.Options;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthRateLimitPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Authentication_rate_limits_bind_from_the_named_configuration_section()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7",
|
||||
[$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3",
|
||||
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration
|
||||
.GetSection(AuthRateLimitOptions.SectionName)
|
||||
.Get<AuthRateLimitOptions>();
|
||||
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal(7, options.PasswordPermitLimit);
|
||||
Assert.Equal(600, options.PasswordWindowSeconds);
|
||||
Assert.Equal(3, options.SmsPermitLimit);
|
||||
Assert.Equal(90, options.SmsWindowSeconds);
|
||||
Assert.Equal(4, options.MfaPermitLimit);
|
||||
Assert.Equal(120, options.MfaWindowSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authentication_rate_limit_values_must_be_positive()
|
||||
{
|
||||
var options = new AuthRateLimitOptions
|
||||
{
|
||||
PasswordPermitLimit = 0,
|
||||
MfaWindowSeconds = 0
|
||||
};
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
var valid = Validator.TryValidateObject(
|
||||
options,
|
||||
new ValidationContext(options),
|
||||
validationResults,
|
||||
validateAllProperties: true);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Equal(2, validationResults.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_login_uses_the_password_named_policy()
|
||||
{
|
||||
AssertPolicy(nameof(AuthController.LoginWithPassword), AuthRateLimitPolicies.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sms_send_uses_the_sms_named_policy()
|
||||
{
|
||||
AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.SetupTotp))]
|
||||
[InlineData(nameof(AuthController.ConfirmTotp))]
|
||||
[InlineData(nameof(AuthController.VerifyTotp))]
|
||||
public void Mfa_challenge_endpoints_use_the_mfa_named_policy(string methodName)
|
||||
{
|
||||
AssertPolicy(methodName, AuthRateLimitPolicies.Mfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_partition_combines_account_and_ip_without_exposing_the_account()
|
||||
{
|
||||
var first = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Password,
|
||||
"""{"Phone":"13800000000","password":"secret"}""",
|
||||
"127.0.0.1");
|
||||
var same = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Password,
|
||||
"""{"phone":"13800000000","password":"different"}""",
|
||||
"127.0.0.1");
|
||||
var differentAccount = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Password,
|
||||
"""{"phone":"13900000000","password":"secret"}""",
|
||||
"127.0.0.1");
|
||||
var differentIp = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Password,
|
||||
"""{"phone":"13800000000","password":"secret"}""",
|
||||
"127.0.0.2");
|
||||
|
||||
Assert.Equal(first, same);
|
||||
Assert.NotEqual(first, differentAccount);
|
||||
Assert.NotEqual(first, differentIp);
|
||||
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("secret", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sms_partition_combines_phone_and_ip_without_exposing_the_phone()
|
||||
{
|
||||
var first = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Sms,
|
||||
"""{"phone":"13800000000","deviceId":"device-one"}""",
|
||||
"127.0.0.1");
|
||||
var differentPhone = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Sms,
|
||||
"""{"phone":"13900000000","deviceId":"device-one"}""",
|
||||
"127.0.0.1");
|
||||
var differentIp = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Sms,
|
||||
"""{"phone":"13800000000","deviceId":"device-one"}""",
|
||||
"127.0.0.2");
|
||||
|
||||
Assert.NotEqual(first, differentPhone);
|
||||
Assert.NotEqual(first, differentIp);
|
||||
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mfa_partition_uses_the_challenge_token_and_resets_the_request_body()
|
||||
{
|
||||
const string body = """{"challengeToken":"challenge-one","code":"123456"}""";
|
||||
var first = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
body,
|
||||
"127.0.0.1");
|
||||
var second = await CapturePartitionAsync(
|
||||
AuthRateLimitPolicies.Mfa,
|
||||
"""{"challengeToken":"challenge-two","code":"123456"}""",
|
||||
"127.0.0.1");
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.DoesNotContain("challenge-one", first, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(string methodName, string expectedPolicy)
|
||||
{
|
||||
var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var attribute = method?.GetCustomAttribute<EnableRateLimitingAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(expectedPolicy, attribute.PolicyName);
|
||||
}
|
||||
|
||||
private static async Task<string> CapturePartitionAsync(
|
||||
string policyName,
|
||||
string json,
|
||||
string ipAddress)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(ipAddress);
|
||||
context.Request.Method = HttpMethods.Post;
|
||||
context.Request.ContentType = "application/json";
|
||||
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
context.SetEndpoint(new Endpoint(
|
||||
_ => Task.CompletedTask,
|
||||
new EndpointMetadataCollection(new EnableRateLimitingAttribute(policyName)),
|
||||
"auth-rate-limit-test"));
|
||||
|
||||
string? partition = null;
|
||||
var middleware = new AuthRateLimitPartitionMiddleware(async nextContext =>
|
||||
{
|
||||
partition = AuthRateLimitPartitionKey.Resolve(nextContext, policyName);
|
||||
using var reader = new StreamReader(
|
||||
nextContext.Request.Body,
|
||||
Encoding.UTF8,
|
||||
leaveOpen: true);
|
||||
Assert.Equal(json, await reader.ReadToEndAsync());
|
||||
});
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
return Assert.IsType<string>(partition);
|
||||
}
|
||||
}
|
||||
287
Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs
Normal file
287
Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Auth;
|
||||
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 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 Backend_session_and_refresh_fail_immediately_after_the_last_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,
|
||||
Module = "test"
|
||||
},
|
||||
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, mfaSatisfied: true);
|
||||
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 revoked backend session"))
|
||||
{
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
||||
Assert.Null(await store.ValidateAccessSessionAsync(
|
||||
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Verify revoked backend family"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var session = await dbContext.AuthSessions.SingleAsync(item => item.Id == locator.SessionId);
|
||||
Assert.NotNull(session.RevokedAt);
|
||||
Assert.Equal("realm_access_revoked", session.RevokedReason);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
bool mfaSatisfied = false)
|
||||
{
|
||||
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",
|
||||
mfaSatisfied,
|
||||
"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);
|
||||
}
|
||||
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
internal sealed record TestAuthenticationTokens(string AccessToken, string RefreshToken);
|
||||
|
||||
internal static class AuthenticationTestClientExtensions
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, string> AuthenticatorKeys = new(StringComparer.Ordinal);
|
||||
|
||||
public static async Task<TestAuthenticationTokens> LoginAsTenantAsync(
|
||||
this HttpClient client,
|
||||
Guid tenantId,
|
||||
string identifier,
|
||||
string password = PasswordTestUserExtensions.TestPassword)
|
||||
{
|
||||
SetTenantHeader(client, tenantId);
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Identifier = identifier,
|
||||
Password = password
|
||||
});
|
||||
|
||||
return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier);
|
||||
}
|
||||
|
||||
public static async Task<TestAuthenticationTokens> CompleteTenantAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
Guid tenantId,
|
||||
string authenticatorCacheKey)
|
||||
{
|
||||
SetTenantHeader(client, tenantId);
|
||||
using var authentication = await ReadSuccessfulJsonAsync(response);
|
||||
var root = authentication.RootElement;
|
||||
var status = root.GetProperty("status").GetString();
|
||||
|
||||
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
var challengeToken = root.GetProperty("challengeToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
|
||||
var keyId = $"{tenantId:N}:{authenticatorCacheKey}";
|
||||
|
||||
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
|
||||
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
|
||||
AuthenticatorKeys[keyId] = sharedKey;
|
||||
|
||||
var confirmResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/confirm",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(sharedKey)
|
||||
});
|
||||
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
|
||||
return ReadTokens(
|
||||
confirmation.RootElement
|
||||
.GetProperty("authentication")
|
||||
.GetProperty("user")
|
||||
.GetProperty("tokens"));
|
||||
}
|
||||
|
||||
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
|
||||
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
|
||||
{
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(existingKey)
|
||||
});
|
||||
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
|
||||
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken);
|
||||
}
|
||||
|
||||
private static void SetTenantHeader(HttpClient client, Guid tenantId)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadSuccessfulJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Authentication request failed with {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(body);
|
||||
}
|
||||
|
||||
private static TestAuthenticationTokens ReadTokens(JsonElement tokens)
|
||||
{
|
||||
var accessToken = tokens.GetProperty("accessToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication response did not contain an access token.");
|
||||
var refreshToken = tokens.GetProperty("refreshToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication response did not contain a refresh token.");
|
||||
return new TestAuthenticationTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
internal static string GenerateTotp(string sharedKey)
|
||||
{
|
||||
var secret = DecodeBase32(sharedKey);
|
||||
var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30;
|
||||
Span<byte> counterBytes = stackalloc byte[8];
|
||||
for (var index = counterBytes.Length - 1; index >= 0; index--)
|
||||
{
|
||||
counterBytes[index] = (byte)(counter & 0xff);
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
var hash = HMACSHA1.HashData(secret, counterBytes);
|
||||
var offset = hash[^1] & 0x0f;
|
||||
var binaryCode = ((hash[offset] & 0x7f) << 24) |
|
||||
(hash[offset + 1] << 16) |
|
||||
(hash[offset + 2] << 8) |
|
||||
hash[offset + 3];
|
||||
return (binaryCode % 1_000_000).ToString("D6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase32(string value)
|
||||
{
|
||||
var normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.TrimEnd('=')
|
||||
.ToUpperInvariant();
|
||||
var output = new byte[normalized.Length * 5 / 8];
|
||||
var buffer = 0;
|
||||
var bitsInBuffer = 0;
|
||||
var outputIndex = 0;
|
||||
|
||||
foreach (var character in normalized)
|
||||
{
|
||||
var digit = character switch
|
||||
{
|
||||
>= 'A' and <= 'Z' => character - 'A',
|
||||
>= '2' and <= '7' => character - '2' + 26,
|
||||
_ => throw new FormatException("Authenticator shared key is not valid Base32.")
|
||||
};
|
||||
buffer = (buffer << 5) | digit;
|
||||
bitsInBuffer += 5;
|
||||
if (bitsInBuffer < 8)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
output[outputIndex++] = (byte)(buffer >> (bitsInBuffer - 8));
|
||||
bitsInBuffer -= 8;
|
||||
buffer &= (1 << bitsInBuffer) - 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
91
Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs
Normal file
91
Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class BackofficeUiBootstrapTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TenantUiBootstrap_ReturnsOnlyMenusAllowedByEffectivePermissions()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var phone = "13710000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Scoped UI Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Dashboard Operator"
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new BackendPermission
|
||||
{
|
||||
Code = BackendPermissions.TenantDashboardView,
|
||||
Name = "Tenant dashboard",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = "tenant_dashboard",
|
||||
IsSystem = true
|
||||
},
|
||||
new TenantBackendRole
|
||||
{
|
||||
Id = roleId,
|
||||
TenantId = tenantId,
|
||||
Code = "dashboard_operator",
|
||||
Name = "Dashboard Operator",
|
||||
Status = BackendRoleStatus.Active,
|
||||
DataScope = JsonSerializer.SerializeToElement(new { mode = "self" })
|
||||
},
|
||||
new TenantBackendRolePermission
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = roleId,
|
||||
PermissionCode = BackendPermissions.TenantDashboardView
|
||||
},
|
||||
new TenantBackendUserRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||
|
||||
using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap");
|
||||
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(
|
||||
[BackendPermissions.TenantDashboardView],
|
||||
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
|
||||
Assert.Equal(
|
||||
["tenant.dashboard"],
|
||||
bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString()));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, roleManagementResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
@@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class CommerceEndpointTests
|
||||
{
|
||||
private static readonly JwtOptions JwtOptions = new()
|
||||
{
|
||||
Issuer = "tiku-backend",
|
||||
Audience = "tiku-api",
|
||||
SigningKey = "development-only-tiku-signing-key-change-before-production"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_commerce_request_returns_401()
|
||||
{
|
||||
@@ -60,11 +50,10 @@ public sealed class CommerceEndpointTests
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
]));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
@@ -343,7 +332,6 @@ public sealed class CommerceEndpointTests
|
||||
var userId = Guid.NewGuid();
|
||||
var planId = Guid.NewGuid();
|
||||
var phone = "13800000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant
|
||||
@@ -357,15 +345,7 @@ public sealed class CommerceEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Commerce User"
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new SvipPlan
|
||||
{
|
||||
Id = planId,
|
||||
@@ -401,44 +381,7 @@ public sealed class CommerceEndpointTests
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string CreateToken(IEnumerable<Claim> claims)
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
JwtOptions.Issuer,
|
||||
JwtOptions.Audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string Phone);
|
||||
|
||||
@@ -108,9 +108,6 @@ public sealed class CommissionEndpointTests
|
||||
User(admin, "tenant_admin"),
|
||||
User(referrer, "sales"),
|
||||
User(student, "student"),
|
||||
Identity(admin),
|
||||
Identity(referrer),
|
||||
Identity(student),
|
||||
Membership(admin, TenantRole.TenantAdmin),
|
||||
Membership(referrer, TenantRole.Sales),
|
||||
Membership(student, TenantRole.Student),
|
||||
@@ -156,22 +153,13 @@ public sealed class CommissionEndpointTests
|
||||
return new CommissionSeed(tenantId, admin, referrer, student);
|
||||
}
|
||||
|
||||
private static User User(LoginSeed seed, string role) => new() { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role };
|
||||
private static User User(LoginSeed seed, string role) =>
|
||||
new User { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }.WithTestPassword();
|
||||
private static TenantMembership Membership(LoginSeed seed, TenantRole role) => new() { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active };
|
||||
private static UserIdentity Identity(LoginSeed seed) => new() { UserId = seed.UserId, Provider = "password", ProviderSubject = seed.Phone, Phone = seed.Phone, SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) };
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Password = "passw0rd!" });
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString());
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse($$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
@@ -168,6 +168,79 @@ public sealed class ContentManagementEndpointTests
|
||||
Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContentEntries_ApplySelfAndRestrictedScopesAndHideUnauthorizedUpdates()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var allowedRegionId = Guid.NewGuid();
|
||||
var outsideRegionId = Guid.NewGuid();
|
||||
var regionalCreatorId = Guid.NewGuid();
|
||||
var outsideCreatorId = Guid.NewGuid();
|
||||
var ownEntry = new ContentEntry
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
RegionId = outsideRegionId,
|
||||
EntryKey = "own-entry",
|
||||
Name = "Own Entry",
|
||||
CreatedBy = seed.UserId
|
||||
};
|
||||
var regionalEntry = new ContentEntry
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
RegionId = allowedRegionId,
|
||||
EntryKey = "regional-entry",
|
||||
Name = "Regional Entry",
|
||||
CreatedBy = regionalCreatorId
|
||||
};
|
||||
var outsideEntry = new ContentEntry
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
RegionId = outsideRegionId,
|
||||
EntryKey = "outside-entry",
|
||||
Name = "Outside Entry",
|
||||
CreatedBy = outsideCreatorId
|
||||
};
|
||||
await factory.SeedAsync(
|
||||
new Tiku.Domain.Catalog.Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" },
|
||||
new Tiku.Domain.Catalog.Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" },
|
||||
new User { Id = regionalCreatorId, Name = "Regional Creator" },
|
||||
new User { Id = outsideCreatorId, Name = "Outside Creator" },
|
||||
ownEntry,
|
||||
regionalEntry,
|
||||
outsideEntry);
|
||||
await SetDataScopeAsync(factory, seed.TenantId, new { mode = "self" });
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
using var selfResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true");
|
||||
using var selfJson = await ReadJsonAsync(selfResponse);
|
||||
|
||||
await SetDataScopeAsync(factory, seed.TenantId, new
|
||||
{
|
||||
mode = "restricted",
|
||||
regionIds = new[] { allowedRegionId },
|
||||
includesSelf = false
|
||||
});
|
||||
using var restrictedResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true");
|
||||
using var restrictedJson = await ReadJsonAsync(restrictedResponse);
|
||||
using var deniedUpdate = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/entries",
|
||||
new UpsertContentEntryDto
|
||||
{
|
||||
Id = outsideEntry.Id,
|
||||
RegionId = outsideRegionId,
|
||||
EntryKey = outsideEntry.EntryKey,
|
||||
Name = "Must stay hidden"
|
||||
});
|
||||
|
||||
Assert.Equal([ownEntry.Id], selfJson.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(item => item.GetProperty("id").GetGuid()));
|
||||
Assert.Equal([regionalEntry.Id], restrictedJson.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(item => item.GetProperty("id").GetGuid()));
|
||||
Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<Guid> CreateEntryAsync(HttpClient client)
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
@@ -187,8 +260,6 @@ public sealed class ContentManagementEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13700000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -203,21 +274,13 @@ public sealed class ContentManagementEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -227,20 +290,17 @@ public sealed class ContentManagementEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var role = dbContext.TenantBackendRoles.Single(item =>
|
||||
item.TenantId == tenantId && item.Code == "integration_test_admin");
|
||||
role.DataScope = JsonSerializer.SerializeToElement(value);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -249,10 +309,4 @@ public sealed class ContentManagementEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,49 +121,20 @@ public sealed class CrmEndpointTests
|
||||
var phone = "13800002001";
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" },
|
||||
new User { Id = userId, Phone = phone, Name = "CRM Admin" },
|
||||
new User { Id = userId, Phone = phone, Name = "CRM Admin" }.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!"))
|
||||
});
|
||||
return new LoginSeed(tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class DatabasePermissionServiceAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BusinessMembershipRoleDoesNotOverrideCommissionAndCrmPermissions()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var phone = "13720000000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Permission Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User { Id = userId, Phone = phone, Name = "Permission Operator" }.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new BackendPermission
|
||||
{
|
||||
Code = BackendPermissions.TenantCommissionManage,
|
||||
Name = "Commission",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = "commission"
|
||||
},
|
||||
new BackendPermission
|
||||
{
|
||||
Code = BackendPermissions.TenantCrmManage,
|
||||
Name = "CRM",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = "crm"
|
||||
},
|
||||
new TenantBackendRole
|
||||
{
|
||||
Id = roleId,
|
||||
TenantId = tenantId,
|
||||
Code = "growth_operator",
|
||||
Name = "Growth Operator",
|
||||
Status = BackendRoleStatus.Active,
|
||||
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
|
||||
},
|
||||
new TenantBackendRolePermission
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = roleId,
|
||||
PermissionCode = BackendPermissions.TenantCommissionManage
|
||||
},
|
||||
new TenantBackendRolePermission
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = roleId,
|
||||
PermissionCode = BackendPermissions.TenantCrmManage
|
||||
},
|
||||
new TenantBackendUserRole { TenantId = tenantId, UserId = userId, RoleId = roleId });
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||
|
||||
using var commission = await client.GetAsync("/api/commission/settings");
|
||||
using var referral = await client.GetAsync("/api/referral/stats");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, commission.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, referral.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -225,13 +225,60 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Single(recordsJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegionBackedDirectContent_UsesRestrictedScopeAndReturns404ForOutsideWrite()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var allowedRegionId = Guid.NewGuid();
|
||||
var outsideRegionId = Guid.NewGuid();
|
||||
var allowedSchool = new School
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
RegionId = allowedRegionId,
|
||||
Name = "Allowed School"
|
||||
};
|
||||
var outsideSchool = new School
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
RegionId = outsideRegionId,
|
||||
Name = "Outside School"
|
||||
};
|
||||
await factory.SeedAsync(
|
||||
new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" },
|
||||
new Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" },
|
||||
allowedSchool,
|
||||
outsideSchool);
|
||||
await SetDataScopeAsync(factory, seed.TenantId, new
|
||||
{
|
||||
mode = "restricted",
|
||||
regionIds = new[] { allowedRegionId },
|
||||
includesSelf = false
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
using var listResponse = await client.GetAsync("/api/tenant-content/scoreline/schools");
|
||||
using var list = await ReadJsonAsync(listResponse);
|
||||
using var deniedUpdate = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/scoreline/schools",
|
||||
new DirectSchoolDto
|
||||
{
|
||||
Id = outsideSchool.Id,
|
||||
RegionId = outsideRegionId,
|
||||
Name = "Hidden Update"
|
||||
});
|
||||
|
||||
Assert.Equal([allowedSchool.Id], list.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(item => item.GetProperty("id").GetGuid()));
|
||||
Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"137{Random.Shared.Next(10000000, 99999999)}";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -246,21 +293,13 @@ public sealed class DirectContentEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -270,20 +309,17 @@ public sealed class DirectContentEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var role = dbContext.TenantBackendRoles.Single(item =>
|
||||
item.TenantId == tenantId && item.Code == "integration_test_admin");
|
||||
role.DataScope = JsonSerializer.SerializeToElement(value);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -292,10 +328,4 @@ public sealed class DirectContentEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Infrastructure.Backoffice;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class ExceptionHandlingMiddlewareTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("platform_access_denied", StatusCodes.Status403Forbidden)]
|
||||
[InlineData("role_not_found", StatusCodes.Status404NotFound)]
|
||||
[InlineData("permission_not_found", StatusCodes.Status404NotFound)]
|
||||
[InlineData("system_role_locked", StatusCodes.Status400BadRequest)]
|
||||
[InlineData("tenant_required", StatusCodes.Status400BadRequest)]
|
||||
public async Task Backoffice_exception_is_returned_as_problem_details(string code, int expectedStatus)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/backoffice/test";
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.TraceIdentifier = "backoffice-test-trace";
|
||||
var middleware = new ExceptionHandlingMiddleware(
|
||||
_ => throw new BackofficeException("Backoffice request failed.", code),
|
||||
NullLogger<ExceptionHandlingMiddleware>.Instance,
|
||||
new TestHostEnvironment());
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(expectedStatus, context.Response.StatusCode);
|
||||
context.Response.Body.Position = 0;
|
||||
using var document = await JsonDocument.ParseAsync(context.Response.Body);
|
||||
var root = document.RootElement;
|
||||
Assert.Equal("Backoffice request failed.", root.GetProperty("title").GetString());
|
||||
Assert.Equal(expectedStatus, root.GetProperty("status").GetInt32());
|
||||
Assert.Equal("/api/backoffice/test", root.GetProperty("instance").GetString());
|
||||
Assert.Equal(code, root.GetProperty("code").GetString());
|
||||
Assert.Equal("backoffice-test-trace", root.GetProperty("traceId").GetString());
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = Environments.Production;
|
||||
public string ApplicationName { get; set; } = "Tiku.IntegrationTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
@@ -411,8 +411,6 @@ public sealed class LearningEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13900000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -425,21 +423,13 @@ public sealed class LearningEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Learning User"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -507,20 +497,7 @@ public sealed class LearningEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -539,10 +516,4 @@ public sealed class LearningEndpointTests
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
22
Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs
Normal file
22
Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Domain.Identity;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
internal static class PasswordTestUserExtensions
|
||||
{
|
||||
public const string TestPassword = "passw0rd!123";
|
||||
|
||||
public static User WithTestPassword(this User user)
|
||||
{
|
||||
user.UserName ??= user.Phone ?? user.Email ?? user.Id.ToString("N");
|
||||
user.NormalizedUserName ??= user.UserName.ToUpperInvariant();
|
||||
var hasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
|
||||
{
|
||||
IterationCount = 210_000
|
||||
}));
|
||||
user.PasswordHash = hasher.HashPassword(user, TestPassword);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
@@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class PointsEndpointTests
|
||||
{
|
||||
private static readonly JwtOptions JwtOptions = new()
|
||||
{
|
||||
Issuer = "tiku-backend",
|
||||
Audience = "tiku-api",
|
||||
SigningKey = "development-only-tiku-signing-key-change-before-production"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_points_request_returns_401()
|
||||
{
|
||||
@@ -57,11 +47,10 @@ public sealed class PointsEndpointTests
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
||||
new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString())
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/points/summary");
|
||||
@@ -175,7 +164,6 @@ public sealed class PointsEndpointTests
|
||||
var userId = Guid.NewGuid();
|
||||
var exchangeItemId = Guid.NewGuid();
|
||||
var phone = "13800000001";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant
|
||||
@@ -189,15 +177,7 @@ public sealed class PointsEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Points User"
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new PointActivityTask
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -248,44 +228,7 @@ public sealed class PointsEndpointTests
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, PointSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string CreateToken(IEnumerable<Claim> claims)
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
JwtOptions.Issuer,
|
||||
JwtOptions.Audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private sealed record PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone);
|
||||
|
||||
@@ -32,15 +32,42 @@ public sealed class ProductionConfigurationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_rejects_the_committed_development_jwt_key()
|
||||
public void Production_requires_an_explicit_rsa_private_key()
|
||||
{
|
||||
var options = new JwtOptions
|
||||
{
|
||||
SigningKey = OptionsValidation.DevelopmentSigningKey
|
||||
KeyId = "production-key",
|
||||
PrivateKeyPem = string.Empty
|
||||
};
|
||||
|
||||
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
|
||||
Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: false));
|
||||
|
||||
options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem;
|
||||
Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jwt_configuration_rejects_development_kid_malformed_keys_and_current_kid_in_old_key_set()
|
||||
{
|
||||
var options = new JwtOptions
|
||||
{
|
||||
KeyId = "development-ephemeral",
|
||||
PrivateKeyPem = TestJwtKeys.PrivateKeyPem
|
||||
};
|
||||
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
|
||||
|
||||
options.KeyId = "current-key";
|
||||
options.PrivateKeyPem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----";
|
||||
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false));
|
||||
|
||||
options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem;
|
||||
options.PublicKeys[options.KeyId] = TestJwtKeys.PublicKeyPem;
|
||||
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false));
|
||||
|
||||
options.PublicKeys.Clear();
|
||||
options.PublicKeys["old-key"] = TestJwtKeys.PublicKeyPem;
|
||||
Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -194,7 +194,6 @@ public sealed class ProfileEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"136{Random.Shared.Next(10000000, 99999999)}";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -209,21 +208,13 @@ public sealed class ProfileEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Student"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -233,20 +224,7 @@ public sealed class ProfileEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -255,10 +233,4 @@ public sealed class ProfileEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
250
Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs
Normal file
250
Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class RbacAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TenantPolicy_RequiresCurrentMembershipPermissionAndMfa()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var snapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
tenantPermissions: [BackendPermissions.TenantRoleManage]);
|
||||
await using var provider = Services(snapshot);
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var allowed = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var missingMfa = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: false),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
var wrongTenant = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", Guid.NewGuid(), hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
|
||||
Assert.True(allowed.Succeeded);
|
||||
Assert.False(missingMfa.Succeeded);
|
||||
Assert.False(wrongTenant.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformPolicy_RejectsTenantRealmEvenWhenUserHasPlatformPermission()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var snapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
platformPermissions: [BackendPermissions.PlatformRoleManage]);
|
||||
await using var provider = Services(snapshot);
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var tenantRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
var platformRealm = await authorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
null,
|
||||
BackendPermissions.PlatformRoleManage);
|
||||
|
||||
Assert.False(tenantRealm.Succeeded);
|
||||
Assert.True(platformRealm.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PermissionPolicy_DoesNotUseJwtRoleClaims()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var snapshot = Snapshot(userId, tenantId);
|
||||
await using var provider = Services(snapshot);
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClaimTypes.Role, "TenantOwner"));
|
||||
|
||||
var result = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
null,
|
||||
BackendPermissions.TenantRoleManage);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllScopePolicy_RejectsRestrictedOrSelfDataScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var selfSnapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
tenantPermissions: [BackendPermissions.TenantContentManage]);
|
||||
await using var selfProvider = Services(selfSnapshot);
|
||||
var denied = await selfProvider.GetRequiredService<IAuthorizationService>().AuthorizeAsync(
|
||||
principal,
|
||||
null,
|
||||
TikuPolicies.TenantContentManageAllScope);
|
||||
|
||||
var allScope = new CurrentDataScope(DataScopeMode.All, new HashSet<Guid>(), new HashSet<Guid>(), true);
|
||||
var allSnapshot = Snapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
tenantPermissions: [BackendPermissions.TenantContentManage],
|
||||
dataScope: allScope);
|
||||
await using var allProvider = Services(allSnapshot);
|
||||
var allowed = await allProvider.GetRequiredService<IAuthorizationService>().AuthorizeAsync(
|
||||
principal,
|
||||
null,
|
||||
TikuPolicies.TenantContentManageAllScope);
|
||||
|
||||
Assert.False(denied.Succeeded);
|
||||
Assert.True(allowed.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResourceRequirement_UsesOwnerRegionClassAndTenantBoundary()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
var classId = Guid.NewGuid();
|
||||
var scope = new CurrentDataScope(
|
||||
DataScopeMode.Restricted,
|
||||
new HashSet<Guid> { regionId },
|
||||
new HashSet<Guid> { classId },
|
||||
true);
|
||||
await using var provider = Services(Snapshot(userId, tenantId, dataScope: scope));
|
||||
var authorization = provider.GetRequiredService<IAuthorizationService>();
|
||||
var principal = Principal(userId, "tenant", tenantId, hasMfa: true);
|
||||
var requirement = new TenantResourceAccessRequirement();
|
||||
|
||||
var own = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
new TenantResourceAuthorizationResource(tenantId, OwnerUserId: userId),
|
||||
requirement);
|
||||
var region = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
new TenantResourceAuthorizationResource(tenantId, RegionId: regionId),
|
||||
requirement);
|
||||
var @class = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
new TenantResourceAuthorizationResource(tenantId, ClassId: classId),
|
||||
requirement);
|
||||
var outside = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
new TenantResourceAuthorizationResource(tenantId, RegionId: Guid.NewGuid()),
|
||||
requirement);
|
||||
var otherTenant = await authorization.AuthorizeAsync(
|
||||
principal,
|
||||
new TenantResourceAuthorizationResource(Guid.NewGuid(), OwnerUserId: userId),
|
||||
requirement);
|
||||
|
||||
Assert.True(own.Succeeded);
|
||||
Assert.True(region.Succeeded);
|
||||
Assert.True(@class.Succeeded);
|
||||
Assert.False(outside.Succeeded);
|
||||
Assert.False(otherTenant.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackofficeBootstrapPolicies_RequireCurrentRealmAndEffectiveAccess()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantSnapshot = Snapshot(userId, tenantId);
|
||||
await using var tenantProvider = Services(tenantSnapshot);
|
||||
var tenantAuthorization = tenantProvider.GetRequiredService<IAuthorizationService>();
|
||||
var tenantAllowed = await tenantAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
TikuPolicies.TenantBackofficeBootstrap);
|
||||
|
||||
var platformSnapshot = Snapshot(
|
||||
userId,
|
||||
null,
|
||||
platformPermissions: [BackendPermissions.PlatformDashboardView]);
|
||||
await using var platformProvider = Services(platformSnapshot);
|
||||
var platformAuthorization = platformProvider.GetRequiredService<IAuthorizationService>();
|
||||
var platformAllowed = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "platform", null, hasMfa: true),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
var tenantRealmDenied = await platformAuthorization.AuthorizeAsync(
|
||||
Principal(userId, "tenant", tenantId, hasMfa: true),
|
||||
null,
|
||||
TikuPolicies.PlatformBackofficeBootstrap);
|
||||
|
||||
Assert.True(tenantAllowed.Succeeded);
|
||||
Assert.True(platformAllowed.Succeeded);
|
||||
Assert.False(tenantRealmDenied.Succeeded);
|
||||
}
|
||||
|
||||
private static ServiceProvider Services(CurrentAccessSnapshot snapshot)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ICurrentAccessContext>(new StubCurrentAccessContext(snapshot));
|
||||
services.AddTikuRbacAuthorization();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(
|
||||
Guid userId,
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
bool hasMfa)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new(TikuClaimTypes.Realm, realm)
|
||||
};
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
if (hasMfa)
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.Mfa, "totp"));
|
||||
}
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
private static CurrentAccessSnapshot Snapshot(
|
||||
Guid userId,
|
||||
Guid? tenantId,
|
||||
IEnumerable<string>? tenantPermissions = null,
|
||||
IEnumerable<string>? platformPermissions = null,
|
||||
CurrentDataScope? dataScope = null)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
true,
|
||||
tenantId.HasValue,
|
||||
(tenantPermissions ?? []).ToHashSet(StringComparer.Ordinal),
|
||||
(platformPermissions ?? []).ToHashSet(StringComparer.Ordinal),
|
||||
dataScope ?? CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
private sealed class StubCurrentAccessContext(CurrentAccessSnapshot snapshot) : ICurrentAccessContext
|
||||
{
|
||||
public Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -225,9 +225,6 @@ public sealed class ReferralEndpointTests
|
||||
User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"),
|
||||
User(student.UserId, student.Phone, "Referral Student", "student"),
|
||||
User(admin.UserId, admin.Phone, "Referral Admin", "tenant_admin"),
|
||||
Identity(referrer),
|
||||
Identity(student),
|
||||
Identity(admin),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -273,45 +270,12 @@ public sealed class ReferralEndpointTests
|
||||
Phone = phone,
|
||||
Name = name,
|
||||
PrimaryRole = role
|
||||
};
|
||||
}
|
||||
|
||||
private static UserIdentity Identity(LoginSeed seed)
|
||||
{
|
||||
return new UserIdentity
|
||||
{
|
||||
UserId = seed.UserId,
|
||||
Provider = "password",
|
||||
ProviderSubject = seed.Phone,
|
||||
Phone = seed.Phone,
|
||||
SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!"))
|
||||
};
|
||||
}.WithTestPassword();
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private sealed class FakeReferralQrcodeGenerator : IReferralQrcodeGenerator
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class SecurityFoundationTests
|
||||
{
|
||||
private static readonly JwtOptions JwtOptions = new()
|
||||
{
|
||||
Issuer = "tiku-backend",
|
||||
Audience = "tiku-api",
|
||||
SigningKey = "development-only-tiku-signing-key-change-before-production"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticated_policy_returns_unauthorized_without_token()
|
||||
{
|
||||
@@ -36,15 +25,18 @@ public sealed class SecurityFoundationTests
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
var sessionId = await factory.SeedActiveSessionAsync(
|
||||
userId,
|
||||
tenantId,
|
||||
includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/tenant-admin");
|
||||
@@ -58,11 +50,15 @@ public sealed class SecurityFoundationTests
|
||||
var userId = Guid.NewGuid();
|
||||
await using var factory = CreateFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
var sessionId = await factory.SeedActiveSessionAsync(
|
||||
userId,
|
||||
tenantId,
|
||||
includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
@@ -76,6 +72,73 @@ public sealed class SecurityFoundationTests
|
||||
Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_without_jti_and_iat_is_rejected()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
], includeStandardClaims: false));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_mfa_claim_must_match_the_database_session()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.Mfa, "mfa")
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_with_unknown_kid_is_rejected()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
TestJwtKeys.CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
], keyId: "unknown-key"));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_rate_limiter_returns_too_many_requests_problem()
|
||||
{
|
||||
@@ -129,19 +192,4 @@ public sealed class SecurityFoundationTests
|
||||
return new ApiTestFactory();
|
||||
}
|
||||
|
||||
private static string CreateToken(IEnumerable<Claim> claims)
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
JwtOptions.Issuer,
|
||||
JwtOptions.Audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
94
Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs
Normal file
94
Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class SmsVerificationConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Concurrent_verification_consumes_a_code_exactly_once()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedCodeAsync(factory, "123456");
|
||||
|
||||
var results = await Task.WhenAll(
|
||||
TryVerifyAsync(factory, seed.TenantId, "123456"),
|
||||
TryVerifyAsync(factory, seed.TenantId, "123456"));
|
||||
|
||||
Assert.Single(results, succeeded => succeeded);
|
||||
using var scope = factory.CreateSystemScope("Verify concurrent SMS consumption");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId);
|
||||
Assert.Equal(SmsVerificationStatus.Verified, verification.Status);
|
||||
Assert.NotNull(verification.ConsumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_invalid_attempts_atomically_block_on_the_fifth_failure()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedCodeAsync(factory, "123456");
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 5)
|
||||
.Select(_ => TryVerifyAsync(factory, seed.TenantId, "999999")));
|
||||
|
||||
Assert.DoesNotContain(true, results);
|
||||
using var scope = factory.CreateSystemScope("Verify concurrent SMS blocking");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId);
|
||||
Assert.Equal(5, verification.Attempts);
|
||||
Assert.Equal(SmsVerificationStatus.Blocked, verification.Status);
|
||||
}
|
||||
|
||||
private static async Task<bool> TryVerifyAsync(ApiTestFactory factory, Guid tenantId, string code)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Concurrent SMS verification");
|
||||
var service = scope.ServiceProvider.GetRequiredService<ISmsVerificationService>();
|
||||
try
|
||||
{
|
||||
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, code);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidCredentialsException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid CodeId)> SeedCodeAsync(
|
||||
ApiTestFactory factory,
|
||||
string code)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Read SMS test options");
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
|
||||
var tenantId = Guid.NewGuid();
|
||||
var verification = new SmsVerificationCode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Phone = "13800000000",
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
code,
|
||||
options.CodePepper),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Concurrent SMS Tenant"
|
||||
},
|
||||
verification);
|
||||
return (tenantId, verification.Id);
|
||||
}
|
||||
}
|
||||
@@ -251,38 +251,18 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_manage_role_templates_members_and_audit_logs()
|
||||
public async Task Tenant_admin_can_manage_members_and_audit_logs()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var roleResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/role-templates",
|
||||
new UpsertTenantAdminRoleTemplateDto
|
||||
{
|
||||
Code = "teacher-basic",
|
||||
Name = "教师基础权限",
|
||||
BaseRole = "teacher",
|
||||
Permissions = JsonSerializer.SerializeToElement(new Dictionary<string, bool>
|
||||
{
|
||||
["classes:read"] = true,
|
||||
["students:read"] = true
|
||||
}),
|
||||
FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary<string, bool>
|
||||
{
|
||||
["student.phone"] = false
|
||||
})
|
||||
});
|
||||
var roleJson = await ReadJsonAsync(roleResponse);
|
||||
var roleTemplateId = roleJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var memberResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/members",
|
||||
new UpsertTenantAdminMemberDto
|
||||
{
|
||||
RoleTemplateId = roleTemplateId,
|
||||
Role = "teacher",
|
||||
User = new TenantAdminUserLookupDto
|
||||
{
|
||||
Phone = "13900000004",
|
||||
@@ -290,15 +270,12 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
}
|
||||
});
|
||||
var membersResponse = await client.GetAsync("/api/tenant-admin/members?role=teacher");
|
||||
var permissionsResponse = await client.GetAsync("/api/tenant-admin/permissions");
|
||||
var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.role_template");
|
||||
var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.member");
|
||||
var membersJson = await ReadJsonAsync(membersResponse);
|
||||
var auditJson = await ReadJsonAsync(auditResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, membersResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, permissionsResponse.StatusCode);
|
||||
Assert.Single(membersJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.NotEmpty(auditJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
@@ -394,8 +371,6 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"137{Random.Shared.Next(10000000, 99999999)}";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -410,21 +385,13 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
@@ -434,20 +401,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -456,10 +410,4 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -203,12 +204,59 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrderAndRefundOperations_ApplySelfAndRestrictedScopesInSql()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
|
||||
var allowedRegionId = Guid.NewGuid();
|
||||
var otherRegionId = Guid.NewGuid();
|
||||
var regionalUserId = Guid.NewGuid();
|
||||
var outsideUserId = Guid.NewGuid();
|
||||
var ownOrder = NewPaidOrder(seed.TenantId, seed.UserId, otherRegionId, "SELF-ORDER");
|
||||
var regionalOrder = NewPaidOrder(seed.TenantId, regionalUserId, allowedRegionId, "REGION-ORDER");
|
||||
var outsideOrder = NewPaidOrder(seed.TenantId, outsideUserId, otherRegionId, "OUTSIDE-ORDER");
|
||||
await factory.SeedAsync(
|
||||
new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" },
|
||||
new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "Other Region" },
|
||||
new User { Id = regionalUserId, Name = "Regional Buyer" },
|
||||
new User { Id = outsideUserId, Name = "Outside Buyer" },
|
||||
ownOrder,
|
||||
regionalOrder,
|
||||
outsideOrder);
|
||||
await SetAdminDataScopeAsync(factory, seed.TenantId, new { mode = "self" });
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
var selfOrders = await client.GetFromJsonAsync<AdminOrderList>("/api/tenant-commerce/orders");
|
||||
|
||||
await SetAdminDataScopeAsync(factory, seed.TenantId, new
|
||||
{
|
||||
mode = "restricted",
|
||||
regionIds = new[] { allowedRegionId },
|
||||
includesSelf = false
|
||||
});
|
||||
var regionalOrders = await client.GetFromJsonAsync<AdminOrderList>("/api/tenant-commerce/orders");
|
||||
using var deniedRefund = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/refunds",
|
||||
new CreateRefundRequestDto { OrderId = outsideOrder.Id, AmountCents = 100 });
|
||||
using var allowedRefund = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/refunds",
|
||||
new CreateRefundRequestDto { OrderId = regionalOrder.Id, AmountCents = 100 });
|
||||
|
||||
Assert.NotNull(selfOrders);
|
||||
Assert.Equal(["SELF-ORDER"], selfOrders.Items.Select(item => item.OrderNo));
|
||||
Assert.NotNull(regionalOrders);
|
||||
Assert.Equal(["REGION-ORDER"], regionalOrders.Items.Select(item => item.OrderNo));
|
||||
Assert.Equal(HttpStatusCode.NotFound, deniedRefund.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13800000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -221,21 +269,13 @@ public sealed class TenantCommerceEndpointTests
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Commerce User"
|
||||
},
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return new LoginSeed(tenantId, userId, phone);
|
||||
@@ -243,28 +283,28 @@ public sealed class TenantCommerceEndpointTests
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new()
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
RegionId = regionId,
|
||||
OrderNo = orderNo,
|
||||
Status = OrderStatus.Paid,
|
||||
AmountCents = 1_000,
|
||||
PaidAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static async Task SetAdminDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var role = await dbContext.TenantBackendRoles.SingleAsync(item =>
|
||||
item.TenantId == tenantId && item.Code == "integration_test_admin");
|
||||
role.DataScope = JsonSerializer.SerializeToElement(value);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
98
Tiku.IntegrationTests/Api/TestJwtKeys.cs
Normal file
98
Tiku.IntegrationTests/Api/TestJwtKeys.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
internal static class TestJwtKeys
|
||||
{
|
||||
public const string Issuer = "tiku-backend";
|
||||
public const string Audience = "tiku-api";
|
||||
public const string KeyId = "integration-test-rsa-key";
|
||||
|
||||
public static string PrivateKeyPem { get; } = CreatePrivateKeyPem();
|
||||
public static string PublicKeyPem { get; } = CreatePublicKeyPem();
|
||||
|
||||
public static string CreateToken(
|
||||
IEnumerable<Claim> claims,
|
||||
AuthRealm realm = AuthRealm.Tenant,
|
||||
bool includeStandardClaims = true,
|
||||
string? keyId = null)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(PrivateKeyPem);
|
||||
var key = new RsaSecurityKey(rsa)
|
||||
{
|
||||
KeyId = keyId ?? KeyId,
|
||||
CryptoProviderFactory = new CryptoProviderFactory
|
||||
{
|
||||
CacheSignatureProviders = false
|
||||
}
|
||||
};
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||
var tokenClaims = claims.ToList();
|
||||
if (tokenClaims.All(claim => claim.Type != TikuClaimTypes.Realm))
|
||||
{
|
||||
tokenClaims.Add(new Claim(
|
||||
TikuClaimTypes.Realm,
|
||||
realm.ToString().ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (includeStandardClaims)
|
||||
{
|
||||
tokenClaims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")));
|
||||
tokenClaims.Add(new Claim(
|
||||
JwtRegisteredClaimNames.Iat,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
Issuer,
|
||||
Audience,
|
||||
tokenClaims,
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private static string CreatePrivateKeyPem()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
return rsa.ExportPkcs8PrivateKeyPem();
|
||||
}
|
||||
|
||||
private static string CreatePublicKeyPem()
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(PrivateKeyPem);
|
||||
return rsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestJwtKeyRing : IJwtKeyRing
|
||||
{
|
||||
private static readonly RsaSecurityKey Key = CreateKey();
|
||||
|
||||
public SigningCredentials SigningCredentials { get; } =
|
||||
new(Key, SecurityAlgorithms.RsaSha256);
|
||||
|
||||
public IReadOnlyCollection<SecurityKey> ValidationKeys { get; } = [Key];
|
||||
|
||||
private static RsaSecurityKey CreateKey()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(TestJwtKeys.PrivateKeyPem);
|
||||
return new RsaSecurityKey(rsa)
|
||||
{
|
||||
KeyId = TestJwtKeys.KeyId,
|
||||
CryptoProviderFactory = new CryptoProviderFactory
|
||||
{
|
||||
CacheSignatureProviders = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,150 @@ namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class ArchitectureBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Production_authorization_does_not_depend_on_legacy_role_claims()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var authorizationFiles = Directory
|
||||
.EnumerateFiles(Path.Combine(root, "Tiku.Api", "Security"), "*.cs", SearchOption.AllDirectories)
|
||||
.Append(Path.Combine(root, "Tiku.Api", "Program.cs"));
|
||||
var forbidden = new[]
|
||||
{
|
||||
"TikuClaimTypes.TenantRole",
|
||||
"TenantRoleAuthorization",
|
||||
"PrimaryRole"
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
authorizationFiles,
|
||||
forbidden,
|
||||
"Production authorization still depends on a legacy role claim or primary role");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backoffice_controllers_do_not_construct_platform_access_flags()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var controllerFiles = Directory.EnumerateFiles(
|
||||
Path.Combine(root, "Tiku.Api", "Controllers"),
|
||||
"*Backoffice*Controller.cs",
|
||||
SearchOption.AllDirectories);
|
||||
var forbidden = new[]
|
||||
{
|
||||
"new BackofficeActor(",
|
||||
"IsPlatformAdmin(",
|
||||
"IsPlatform ="
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
controllerFiles,
|
||||
forbidden,
|
||||
"Backoffice controllers must use the resolved access context instead of constructing platform access flags");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackendAuthorizationDoesNotUseMembershipBusinessRoles()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var files = new[]
|
||||
{
|
||||
Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs"),
|
||||
Path.Combine(root, "Tiku.Infrastructure", "Growth", "ReferralService.cs"),
|
||||
Path.Combine(root, "Tiku.Application", "TenantAdmin", "TenantAdminDirectModels.cs"),
|
||||
Path.Combine(root, "Tiku.Api", "Controllers", "TenantAdminDirectController.cs")
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
files,
|
||||
new[]
|
||||
{
|
||||
"item.Role == TenantRole.TenantOwner",
|
||||
"item.Role == TenantRole.TenantAdmin",
|
||||
"TenantRole Role = TenantRole.TenantAdmin",
|
||||
"new TenantAdminActor("
|
||||
},
|
||||
"Backend authorization must use database role permissions rather than membership business roles or fabricated admin actors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Auth_sessions_are_accessed_only_through_the_session_store()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure", "Tiku.Worker" };
|
||||
var allowedFiles = new[]
|
||||
{
|
||||
"TikuDbContext.cs",
|
||||
"AuthSessionStore.cs",
|
||||
"SessionStore.cs"
|
||||
};
|
||||
|
||||
var files = sourceRoots
|
||||
.SelectMany(directory => Directory.EnumerateFiles(
|
||||
Path.Combine(root, directory),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories))
|
||||
.Where(path => !path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.Where(path => !path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Configurations{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.Where(path => !allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal));
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
files,
|
||||
new[] { ".AuthSessions", "Set<AuthSession>" },
|
||||
"AuthSession DbSet access must be encapsulated by IAuthSessionStore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Api_uses_an_authenticated_fallback_policy()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var program = File.ReadAllText(Path.Combine(root, "Tiku.Api", "Program.cs"));
|
||||
|
||||
Assert.Contains("FallbackPolicy", program, StringComparison.Ordinal);
|
||||
Assert.Contains("RequireAuthenticatedUser()", program, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_controller_action_declares_authorization_or_anonymous_access()
|
||||
{
|
||||
var controllerAssembly = typeof(Tiku.Api.Controllers.AuthController).Assembly;
|
||||
var violations = controllerAssembly
|
||||
.GetTypes()
|
||||
.Where(type => !type.IsAbstract && typeof(Microsoft.AspNetCore.Mvc.ControllerBase).IsAssignableFrom(type))
|
||||
.SelectMany(type => type
|
||||
.GetMethods(System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.DeclaredOnly)
|
||||
.Where(method => method
|
||||
.GetCustomAttributes(inherit: true)
|
||||
.OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
||||
.Any())
|
||||
.Select(method => new
|
||||
{
|
||||
Controller = type,
|
||||
Action = method,
|
||||
Metadata = type.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
}))
|
||||
.Where(candidate => !candidate.Metadata.Any(attribute =>
|
||||
attribute is Microsoft.AspNetCore.Authorization.IAuthorizeData or
|
||||
Microsoft.AspNetCore.Authorization.IAllowAnonymous))
|
||||
.Select(candidate => $"{candidate.Controller.FullName}.{candidate.Action.Name}")
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
violations.Length == 0,
|
||||
$"Controller actions without explicit authorization metadata were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Business_code_does_not_bypass_tenant_query_boundaries()
|
||||
{
|
||||
@@ -150,4 +294,23 @@ public sealed class ArchitectureBoundaryTests
|
||||
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
|
||||
private static void AssertNoForbiddenSymbols(
|
||||
string root,
|
||||
IEnumerable<string> files,
|
||||
IReadOnlyCollection<string> forbidden,
|
||||
string failureMessage)
|
||||
{
|
||||
var violations = files
|
||||
.SelectMany(path => File.ReadLines(path)
|
||||
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
|
||||
.Where(candidate => forbidden.Any(symbol =>
|
||||
candidate.line.Contains(symbol, StringComparison.Ordinal)))
|
||||
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
violations.Length == 0,
|
||||
$"{failureMessage}:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ public sealed class PersistenceModelTests
|
||||
Assert.Contains("auth_login_events", tableNames);
|
||||
Assert.Contains("auth_sessions", tableNames);
|
||||
Assert.Contains("sms_send_rate_limits", tableNames);
|
||||
Assert.Contains("tenant_role_templates", tableNames);
|
||||
Assert.Contains("tenant_classes", tableNames);
|
||||
Assert.Contains("tenant_class_members", tableNames);
|
||||
Assert.Contains("tenant_student_notes", tableNames);
|
||||
@@ -510,8 +509,6 @@ public sealed class PersistenceModelTests
|
||||
[InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.Permissions), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.DataScope), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantClass), nameof(TenantClass.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantClassMember), nameof(TenantClassMember.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantStudentNote), nameof(TenantStudentNote.Metadata), "'{}'::jsonb")]
|
||||
|
||||
Reference in New Issue
Block a user