feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

@@ -305,6 +305,123 @@ public sealed class AuthEndpointTests
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Wechat_first_login_is_rejected_without_self_registration_and_leaves_no_identity()
{
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
var tenantId = Guid.NewGuid();
await SeedWechatProviderAsync(factory, tenantId,
new TenantAuthPolicy
{
TenantId = tenantId,
AllowExternalStudentSelfRegistration = false
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
var response = await client.PostAsJsonAsync(
"/api/auth/oauth/wechat-miniapp",
new OAuthCodeDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Code = "wx-code"
});
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.DoesNotContain(dbContext.UserIdentities, item => item.Provider == "wechat_miniapp");
Assert.DoesNotContain(dbContext.TenantMemberships, item => item.TenantId == tenantId);
Assert.DoesNotContain(dbContext.AuthSessions, item => item.TenantId == tenantId);
}
[Theory]
[InlineData(MembershipStatus.Invited)]
[InlineData(MembershipStatus.Disabled)]
public async Task Wechat_login_does_not_reactivate_non_active_membership(MembershipStatus status)
{
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
var tenantId = Guid.NewGuid();
var user = new User { Id = Guid.NewGuid(), Name = "Existing Wechat User" };
await SeedWechatProviderAsync(factory, tenantId,
user,
new UserIdentity
{
UserId = user.Id,
Provider = "wechat_miniapp",
ProviderSubject = "wx-app-id:mini-open-id",
OpenId = "mini-open-id",
UnionId = "union-id"
},
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = status
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
var response = await client.PostAsJsonAsync(
"/api/auth/oauth/wechat-miniapp",
new OAuthCodeDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Code = "wx-code"
});
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(status, dbContext.TenantMemberships.Single(item =>
item.TenantId == tenantId && item.UserId == user.Id).Status);
Assert.DoesNotContain(dbContext.AuthSessions, item =>
item.TenantId == tenantId && item.UserId == user.Id);
}
private static async Task SeedWechatProviderAsync(
ApiTestFactory factory,
Guid tenantId,
params object[] additionalEntities)
{
const string secretRef = "tenant_secrets:identity:wechat_miniapp:default";
var protectedSecret = ProtectTenantSecret(
tenantId,
secretRef,
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }));
var entities = new List<object>
{
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" },
new TenantExternalProvider
{
TenantId = tenantId,
Provider = "wechat_miniapp",
Capability = TenantExternalProviderCapability.Identity,
Status = TenantExternalProviderStatus.Active,
SecretRef = secretRef,
ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id" })
},
new TenantSecret
{
TenantId = tenantId,
Purpose = "identity",
Provider = "wechat_miniapp",
SecretKey = "default",
SecretRef = secretRef,
Status = TenantSecretStatus.Active,
EncryptionKeyId = protectedSecret.KeyId,
EncryptedPayload = protectedSecret.Ciphertext,
EncryptionNonce = protectedSecret.Nonce,
EncryptionTag = protectedSecret.Tag
}
};
entities.AddRange(additionalEntities);
await factory.SeedAsync(entities.ToArray());
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
ApiTestFactory factory)
{