feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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; }

View File

@@ -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"));
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}

View 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;
}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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;
}
}

View File

@@ -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);

View File

@@ -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]

View File

@@ -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();
}
}

View 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);
}
}

View File

@@ -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

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View 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
}
};
}
}

View File

@@ -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)}");
}
}

View File

@@ -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")]