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

@@ -34,8 +34,17 @@ public sealed class ApiTestFactory(
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
public string DatabaseConnectionString => database.ConnectionString;
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
if (configurationOverrides is not null)
{
foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null))
{
builder.UseSetting(pair.Key, pair.Value);
}
}
builder.ConfigureAppConfiguration((_, configuration) =>
{
var values = new Dictionary<string, string?>
@@ -61,7 +70,7 @@ public sealed class ApiTestFactory(
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
descriptor.ServiceType == typeof(TikuDbContext))
.ToArray())
{
services.Remove(descriptor);

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

View File

@@ -0,0 +1,78 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Security;
namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 330;
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()
{
var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
.SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Where(method => method.GetCustomAttributes<HttpMethodAttribute>().Any())
.Select(method => Describe(type, method)))
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', descriptors))))
.ToLowerInvariant();
Assert.True(
descriptors.Length == ExpectedActionCount && hash == ExpectedSha256,
$"Authorization manifest changed. count={descriptors.Length}, sha256={hash}");
}
[Fact]
public async Task Runtime_controller_endpoints_have_authorization_and_audit_metadata()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/api/health");
var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
.Where(endpoint => endpoint.Metadata.GetMetadata<ControllerActionDescriptor>() is not null)
.ToArray();
Assert.NotEmpty(endpoints);
foreach (var endpoint in endpoints)
{
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
var metadata = endpoint.Metadata.GetMetadata<EndpointAuthorizationMetadata>();
if (anonymous)
{
Assert.Null(metadata);
continue;
}
Assert.NotNull(metadata);
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" });
}
}
private static string Describe(Type controller, MethodInfo action)
{
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
var http = action.GetCustomAttributes<HttpMethodAttribute>().ToArray();
var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal));
var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal));
var policies = controller.GetCustomAttributes<AuthorizeAttribute>()
.Concat(action.GetCustomAttributes<AuthorizeAttribute>())
.Select(attribute => attribute.Policy ?? "authenticated")
.Order(StringComparer.Ordinal);
var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) ||
action.IsDefined(typeof(AllowAnonymousAttribute));
return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}";
}
}

View File

@@ -0,0 +1,183 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
namespace Tiku.IntegrationTests.Api;
public sealed class BrowserAuthenticationTests
{
[Fact]
public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000000";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Add("Origin", "http://localhost");
var response = await client.PostAsJsonAsync("/api/browser-auth/login/password", new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
});
var body = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(body);
var cookies = response.Headers.GetValues("Set-Cookie").ToArray();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase);
Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString());
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) &&
value.Contains("secure", StringComparison.OrdinalIgnoreCase) &&
value.Contains("httponly", StringComparison.OrdinalIgnoreCase));
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) &&
value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase));
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie));
}
[Fact]
public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000001";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
})
};
login.Headers.Add("Origin", "http://localhost");
var loginResponse = await client.SendAsync(login);
var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray();
var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie);
var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie);
var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}";
var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null);
var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf);
var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf);
Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value =>
value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal));
}
[Fact]
public async Task Access_cookie_is_ignored_without_same_origin_evidence()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
const string phone = "13890000002";
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" },
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
HandleCookies = false
});
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Identifier = phone,
Password = PasswordTestUserExtensions.TestPassword
})
};
login.Headers.Add("Origin", "http://localhost");
var loginResponse = await client.SendAsync(login);
var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie);
using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/me");
crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
crossSite.Headers.Add("Sec-Fetch-Site", "cross-site");
var rejected = await client.SendAsync(crossSite);
using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/me");
sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
sameOrigin.Headers.Add("Origin", "http://localhost");
var accepted = await client.SendAsync(sameOrigin);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
}
private static async Task<HttpResponseMessage> SendRefreshAsync(
HttpClient client,
string cookieHeader,
string origin,
string? csrf)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/refresh");
request.Headers.Add("Cookie", cookieHeader);
request.Headers.Add("Origin", origin);
if (csrf is not null)
{
request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf);
}
return await client.SendAsync(request);
}
private static string ReadCookie(IEnumerable<string> setCookies, string name)
{
var prefix = name + "=";
var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal));
return header[prefix.Length..header.IndexOf(';')];
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class CapabilityAuthorizationTests
{
[Fact]
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
new ProductModule { Code = "content", Name = "Content" },
new TenantSubscription
{
TenantId = tenantId,
PlanCode = "capability-test",
Status = TenantSubscriptionStatus.Active,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
});
using var scope = factory.CreateSystemScope("Verify capability authorization");
var evaluator = scope.ServiceProvider.GetRequiredService<ICapabilityAccessEvaluator>();
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
{
PlanCode = "capability-test",
ModuleCode = "content"
});
await dbContext.SaveChangesAsync();
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
var subscription = dbContext.TenantSubscriptions.Single(item => item.TenantId == tenantId);
subscription.Status = TenantSubscriptionStatus.PastDue;
await dbContext.SaveChangesAsync();
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
}
}

View File

@@ -85,4 +85,24 @@ public sealed class ProductionConfigurationTests
MasterKey = Convert.ToBase64String(new byte[32])
}));
}
[Fact]
public void Production_network_configuration_requires_formal_hosts_allowed_hosts_and_proxy()
{
var developmentDefaults = new TenantResolutionOptions();
var wildcard = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "*" })
.Build();
Assert.False(OptionsValidation.BeValidTenantResolutionOptions(developmentDefaults, wildcard, true));
var production = new TenantResolutionOptions
{
PlatformHosts = ["admin.example.com"],
TrustedProxyAddresses = ["10.0.0.10"]
};
var explicitHosts = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "admin.example.com;api.example.com" })
.Build();
Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true));
}
}