188 lines
6.8 KiB
C#
188 lines
6.8 KiB
C#
using System.Threading.RateLimiting;
|
|
using Budget.Api.Services;
|
|
using Budget.Infrastructure.Data;
|
|
using Budget.Infrastructure.Services;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using OpenTelemetry;
|
|
using OpenTelemetry.Resources;
|
|
using OpenTelemetry.Trace;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
if (!string.IsNullOrEmpty(otlpEndpoint))
|
|
{
|
|
builder.Logging.AddOpenTelemetry(logging =>
|
|
{
|
|
logging.IncludeFormattedMessage = true;
|
|
logging.IncludeScopes = true;
|
|
});
|
|
|
|
builder.Services.AddOpenTelemetry()
|
|
.ConfigureResource(resource => resource
|
|
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
|
.WithTracing(tracing => tracing
|
|
.AddAspNetCoreInstrumentation(opts =>
|
|
opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api"))
|
|
.AddHttpClientInstrumentation()
|
|
.AddEntityFrameworkCoreInstrumentation())
|
|
.UseOtlpExporter();
|
|
}
|
|
|
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
|
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
|
|
$"Port={builder.Configuration["POSTGRES_PORT"] ?? "5432"};" +
|
|
$"Database={builder.Configuration["POSTGRES_DB"] ?? "budget"};" +
|
|
$"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" +
|
|
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
|
|
|
|
builder.Services.AddSingleton<Budget.Infrastructure.Data.SlowQueryInterceptor>();
|
|
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
|
{
|
|
opt.UseNpgsql(connStr);
|
|
opt.AddInterceptors(sp.GetRequiredService<Budget.Infrastructure.Data.SlowQueryInterceptor>());
|
|
});
|
|
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
|
|
| ForwardedHeaders.XForwardedProto
|
|
| ForwardedHeaders.XForwardedHost;
|
|
|
|
options.KnownIPNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
|
|
var trustedNetworks = builder.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12";
|
|
foreach (var cidr in trustedNetworks.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
var parts = cidr.Split('/');
|
|
var prefix = System.Net.IPAddress.Parse(parts[0]);
|
|
var prefixLength = parts.Length > 1
|
|
? int.Parse(parts[1])
|
|
: (prefix.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 128 : 32);
|
|
options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength));
|
|
}
|
|
});
|
|
|
|
var oidc = builder.Configuration.GetSection("Oidc");
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.Authority = oidc["Authority"];
|
|
options.Audience = oidc["Audience"];
|
|
options.MapInboundClaims = false;
|
|
var metadataAddress = oidc["MetadataAddress"];
|
|
if (!string.IsNullOrEmpty(metadataAddress))
|
|
{
|
|
options.MetadataAddress = metadataAddress;
|
|
options.RequireHttpsMetadata = false;
|
|
}
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
RoleClaimType = "role",
|
|
NameClaimType = "sub",
|
|
};
|
|
});
|
|
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
|
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
|
{
|
|
var key = ctx.User.FindFirst("sub")?.Value
|
|
?? ctx.Connection.RemoteIpAddress?.ToString()
|
|
?? "unknown";
|
|
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 120,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
});
|
|
});
|
|
|
|
options.AddPolicy("writes", ctx =>
|
|
{
|
|
var key = ctx.User.FindFirst("sub")?.Value
|
|
?? ctx.Connection.RemoteIpAddress?.ToString()
|
|
?? "unknown";
|
|
return RateLimitPartition.GetFixedWindowLimiter("writes:" + key, _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 30,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddScoped<BudgetAuthorizationService>();
|
|
builder.Services.AddControllers()
|
|
.AddJsonOptions(opts =>
|
|
opts.JsonSerializerOptions.Converters.Add(
|
|
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
|
var app = builder.Build();
|
|
|
|
// Block startup until OIDC discovery succeeds (handles auth/app container race).
|
|
var jwtOpts = app.Services.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
|
|
.Get(JwtBearerDefaults.AuthenticationScheme);
|
|
var startupLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Startup");
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(2);
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
jwtOpts.ConfigurationManager!.RequestRefresh();
|
|
await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None);
|
|
startupLogger.LogInformation("OIDC discovery succeeded");
|
|
startupLogger.LogInformation("Trusted proxy networks: {Networks}",
|
|
app.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12 (default)");
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (DateTime.UtcNow >= deadline)
|
|
{
|
|
startupLogger.LogWarning(ex, "OIDC discovery timed out after 2 min; continuing startup");
|
|
break;
|
|
}
|
|
startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s");
|
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|
|
|
|
// Apply EF migrations automatically on startup
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await db.Database.MigrateAsync();
|
|
}
|
|
|
|
app.UseForwardedHeaders();
|
|
app.UseMiddleware<ErrorHandlingMiddleware>();
|
|
|
|
app.UseDefaultFiles();
|
|
app.UseStaticFiles();
|
|
|
|
// Authentication must run before the rate limiter so limiter partitions can
|
|
// read the validated "sub" claim; otherwise every authenticated request falls
|
|
// back to the per-IP bucket (auth-hardening-review.md, finding B-1).
|
|
app.UseAuthentication();
|
|
app.UseRateLimiter();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
app.MapFallbackToFile("index.html");
|
|
|
|
app.Run();
|