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

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Tiku.Api.OpenApi;
using Tiku.Api.Security;
namespace Tiku.Api.Configuration;
@@ -7,7 +8,8 @@ internal static class ApiPresentationExtensions
{
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
{
services.AddControllers()
services.AddControllers(options =>
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());

View File

@@ -29,6 +29,7 @@ public static class ApplicationBuilderExtensions
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseMiddleware<BrowserCsrfMiddleware>();
app.UseAuthentication();
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
app.UseRateLimiter();

View File

@@ -29,6 +29,15 @@ internal static class AuthenticationExtensions
var jwtOptions = configuration
.GetSection("Security:Jwt")
.Get<JwtOptions>() ?? new JwtOptions();
services.AddOptions<BrowserAuthOptions>()
.Bind(configuration.GetSection(BrowserAuthOptions.SectionName))
.Validate(
options => options.AllowedOrigins.All(origin =>
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'))),
"BrowserAuth AllowedOrigins must contain only HTTP(S) origins without paths.")
.ValidateOnStart();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -84,11 +93,41 @@ internal static class AuthenticationExtensions
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) &&
IsSameOriginBrowserRequest(context.Request) &&
context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnTokenValidated = ValidateTokenAsync,
OnChallenge = WriteTenantConflictChallengeAsync
};
}
private static bool IsSameOriginBrowserRequest(HttpRequest request)
{
var source = request.Headers.Origin.ToString();
if (string.IsNullOrWhiteSpace(source))
{
source = request.Headers.Referer.ToString();
}
if (Uri.TryCreate(source, UriKind.Absolute, out var uri))
{
return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(
request.Headers["Sec-Fetch-Site"].ToString(),
"same-origin",
StringComparison.OrdinalIgnoreCase);
}
private static async Task ValidateTokenAsync(TokenValidatedContext context)
{
var principal = context.Principal;

View File

@@ -1,6 +1,8 @@
using Serilog;
using Tiku.Application;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Configuration;
@@ -15,14 +17,51 @@ public static class DependencyInjection
preserveStaticLogger: true);
builder.Services.AddApiPresentation();
builder.Services.AddHealthChecks();
builder.Services.AddApplication();
builder.Services.AddNetworkConfiguration(builder.Configuration);
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddApiRateLimiting(builder.Configuration);
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
builder.Configuration,
builder.Environment.IsDevelopment());
builder.Services.AddInfrastructure(connectionString);
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
.Validate(
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
"Redis is required in Production.")
.ValidateOnStart();
if (!string.IsNullOrWhiteSpace(redisConnectionString))
{
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
}
else if (builder.Environment.IsProduction())
{
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
}
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
builder.Services.AddOptions<MessagingOptions>()
.Bind(builder.Configuration.GetSection("RabbitMq"))
.Validate(
options => !builder.Environment.IsProduction() ||
(options.IsConfigured &&
!string.IsNullOrWhiteSpace(options.Username) &&
!string.IsNullOrWhiteSpace(options.Password)),
"Production RabbitMQ requires a valid Host, Username and Password.")
.ValidateOnStart();
builder.Services.AddSingleton(messaging);
if (messaging.IsConfigured)
{
messaging.ConfigureConsumers = false;
builder.Services.AddReliableMessaging(messaging);
}
else if (builder.Environment.IsProduction())
{
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
}
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);

View File

@@ -9,10 +9,15 @@ internal static class NetworkConfigurationExtensions
{
internal static IServiceCollection AddNetworkConfiguration(
this IServiceCollection services,
IConfiguration configuration)
IConfiguration configuration,
IHostEnvironment environment)
{
services.AddOptions<TenantResolutionOptions>()
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName));
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName))
.Validate(
options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()),
"Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.")
.ValidateOnStart();
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =