158 lines
5.3 KiB
C#
158 lines
5.3 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.Diagnostics.HealthChecks;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
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.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr));
|
|
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
options.KnownNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
});
|
|
|
|
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()));
|
|
builder.Services.AddHealthChecks()
|
|
.AddDbContextCheck<AppDbContext>();
|
|
|
|
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");
|
|
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");
|
|
Thread.Sleep(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();
|
|
|
|
app.UseAuthentication();
|
|
app.UseMiddleware<KnownUserMiddleware>();
|
|
app.UseAuthorization();
|
|
|
|
app.UseRateLimiter();
|
|
|
|
app.MapControllers();
|
|
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
|
{
|
|
ResultStatusCodes =
|
|
{
|
|
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
|
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
|
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
|
}
|
|
});
|
|
|
|
app.MapFallbackToFile("index.html");
|
|
|
|
app.Run();
|