Files
budget/src/Budget.Api/Program.cs
T

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