using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using SameBeat.Api.Hubs;
using SameBeat.Api.Konfiguracja;
using SameBeat.Api.Middleware;
using SameBeat.Application.Interfejsy;
using SameBeat.Infrastructure.BazaDanych;
using SameBeat.Infrastructure.Logowanie;
using Serilog;
/// Nazwa polityki rate limitingu dla /api/auth/login -- osobna, surowsza niz reszta /api/* (ETAP 7).
const string PolitykaLogowania = "logowanie";
const string NazwaCiasteczkaAuth = "sb_auth";
const string NazwaPolitykiCors = "PolitykaFrontendu";
var builder = WebApplication.CreateBuilder(args);
// ETAP 7 -- limit rozmiaru body requestu (ochrona przed nadmiernie duzymi payloadami; ta aplikacja
// nie ma zadnego endpointu na upload plikow -- wszystkie DTO to krotkie JSONy, wiec 32KB jest sowitym
// zapasem). Kestrel to jedyny serwer tej aplikacji (Sdk.Web, bez IIS/Nginx w procesie) -- limit tu
// wystarczy, nie trzeba go osobno powtarzac np. w FormOptions (nie uzywamy multipart/form-data).
builder.WebHost.ConfigureKestrel(opcje =>
{
opcje.Limits.MaxRequestBodySize = 32 * 1024;
});
// Serilog jest logerem calej aplikacji od pierwszej linii (rowniez bledow startu, np. zlej
// konfiguracji polaczenia z baza) -- konfiguracja w Infrastructure/Logowanie, katalog logow ponizej.
var katalogLogow = Path.Combine(builder.Environment.ContentRootPath, "logs");
Directory.CreateDirectory(katalogLogow);
Log.Logger = KonfiguracjaSerilog.Zbuduj(builder.Configuration, katalogLogow).CreateLogger();
builder.Host.UseSerilog();
builder.Services.DodajUslugiSameBeat(builder.Configuration);
builder.Services
.AddControllers(opcje => opcje.Conventions.Add(new KonwencjaAutoryzacjiHosta()))
.AddJsonOptions(opcje => opcje.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opcje =>
{
opcje.SwaggerDoc("v1", new OpenApiInfo { Title = "SameBeat API", Version = "v1" });
});
builder.Services.AddSignalR();
// ETAP 8 -- licznik otwartych polaczen SignalR w pamieci procesu (patrz Api/Monitoring/LicznikPolaczenSignalR.cs),
// singleton bo ma zyc caly czas dzialania procesu, wspoldzielony miedzy StanImprezyHub (aktualizacja)
// a HealthController/HostDiagnostykaController (odczyt).
builder.Services.AddSingleton();
var ustawieniaJwtSekcja = builder.Configuration.GetSection("Jwt");
builder.Services
.AddAuthentication(opcje =>
{
opcje.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opcje.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(opcje =>
{
var sekret = ustawieniaJwtSekcja["Sekret"];
opcje.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = ustawieniaJwtSekcja["Wydawca"],
ValidateAudience = true,
ValidAudience = ustawieniaJwtSekcja["Odbiorca"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = string.IsNullOrWhiteSpace(sekret)
? null
: new SymmetricSecurityKey(Encoding.UTF8.GetBytes(sekret)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
// Token JWT siedzi w httpOnly cookie, nie w naglowku Authorization -- trzeba go stamtad
// recznie wyciagnac, inaczej middleware autoryzacji nigdy go nie znajdzie.
opcje.Events = new JwtBearerEvents
{
OnMessageReceived = kontekst =>
{
if (kontekst.Request.Cookies.TryGetValue(NazwaCiasteczkaAuth, out var tokenZCiasteczka))
{
kontekst.Token = tokenZCiasteczka;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var dozwoloneOrigin = builder.Configuration["Cors:DozwoloneOrigin"] ?? "http://localhost:5173";
builder.Services.AddCors(opcje =>
{
opcje.AddPolicy(NazwaPolitykiCors, polityka =>
polityka.WithOrigins(dozwoloneOrigin)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
// ETAP 7 -- rate limiting REST API (Microsoft.AspNetCore.RateLimiting, wbudowane w .NET 8, bez NuGet).
// Klucz partycji to adres IP wywolujacego -- to samo IP moze bic zarowno w rozne endpointy /api/*
// (limit ogolny, GlobalLimiter ponizej) jak i specyficznie w /api/auth/login (osobna, surowsza
// polityka "logowanie" -- ochrona przed brute force, patrz [EnableRateLimiting] w AuthController).
// UWAGA: polityka nalozona przez [EnableRateLimiting] na endpoincie CALKOWICIE ZASTEPUJE GlobalLimiter
// dla tego endpointu (nie sumuja sie) -- login jest wiec liczony WYLACZNIE wg polityki "logowania".
builder.Services.AddRateLimiter(opcje =>
{
opcje.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
opcje.OnRejected = async (kontekstOdrzucenia, token) =>
{
kontekstOdrzucenia.HttpContext.Response.ContentType = "application/json";
await kontekstOdrzucenia.HttpContext.Response.WriteAsync(
JsonSerializer.Serialize(new { blad = "Zbyt wiele zadan z tego adresu. Sprobuj ponownie za chwile." }),
token);
};
// Polityka ogolna -- WYLACZNIE dla /api/* (Swagger, /hub/impreza itd. nie sa nia objete, zeby nie
// dolozyc limitu do negocjacji SignalR, ktora i tak jest chroniona osobno przez autoryzacje/CORS).
opcje.GlobalLimiter = PartitionedRateLimiter.Create(kontekst =>
{
if (!kontekst.Request.Path.StartsWithSegments("/api"))
{
return RateLimitPartition.GetNoLimiter("poza-api");
}
return RateLimitPartition.GetSlidingWindowLimiter(PobierzKluczIp(kontekst), _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 0
});
});
// Surowsza polityka logowania -- 5 prob / 5 minut per IP, NIEZALEZNA od blokady per-konto
// w SerwisLogowania (dwie warstwy, patrz komentarz tam).
opcje.AddPolicy(PolitykaLogowania, kontekst =>
RateLimitPartition.GetSlidingWindowLimiter(PobierzKluczIp(kontekst), _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(5),
SegmentsPerWindow = 5,
QueueLimit = 0
}));
});
var app = builder.Build();
// Globalny lapacz wyjatkow na samym poczatku potoku -- zeby zlapac rowniez bledy z pozniejszych
// middleware (autoryzacja, routing), nie tylko z kontrolerow.
app.UseMiddleware();
// ETAP 7 -- naglowki bezpieczenstwa (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
// na KAZDEJ odpowiedzi, wiec zaraz po lapaczu wyjatkow -- patrz komentarz w tej klasie.
app.UseMiddleware();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
// Seed konta hosta dev + proba nalozenia migracji dziala tylko lokalnie i tylko jesli baza jest
// osiagalna -- na swiezym srodowisku bez postawionego Postgresa API ma sie mimo to normalnie uruchomic.
using var zakresStartu = app.Services.CreateScope();
var logerStartu = zakresStartu.ServiceProvider.GetRequiredService>();
try
{
var kontekst = zakresStartu.ServiceProvider.GetRequiredService();
await kontekst.Database.MigrateAsync();
var uslugaHaszowaniaHasel = zakresStartu.ServiceProvider.GetRequiredService();
await SeedDanych.SeedujKontoHostaDevAsync(kontekst, uslugaHaszowaniaHasel, builder.Configuration, logerStartu);
await SeedDanych.SeedujWydarzenieDevAsync(kontekst, logerStartu);
}
catch (Exception wyjatek)
{
logerStartu.LogWarning(wyjatek,
"Nie udalo sie zastosowac migracji / zasiac konta dev -- baza danych jest prawdopodobnie niedostepna. " +
"API mimo to startuje; uzupelnij ConnectionStrings:BazaDanych, gdy baza bedzie gotowa.");
}
}
app.UseHttpsRedirection();
app.UseCors(NazwaPolitykiCors);
// UseRateLimiter PO UseCors -- zeby odpowiedz 429 (rowniez ta z GlobalLimiter/politykowej blokady)
// nadal miala naglowki CORS i przegladarka mogla ja w ogole odczytac zamiast zgloszic blad CORS
// zamiast prawdziwego bledu 429.
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub("/hub/impreza");
try
{
Log.Information("Start SameBeat.Api.");
app.Run();
}
finally
{
Log.CloseAndFlush();
}
///
/// Klucz partycji rate limitera -- adres IP wywolujacego. RemoteIpAddress bierze pod uwage
/// UseForwardedHeaders, gdyby kiedys aplikacja stanela za reverse proxy/load balancerem (na razie
/// nie jest skonfigurowany -- do uzupelnienia razem z prawdziwym adresem produkcyjnym, patrz appsettings).
///
static string PobierzKluczIp(HttpContext kontekst) =>
kontekst.Connection.RemoteIpAddress?.ToString() ?? "nieznany-adres-ip";
public partial class Program
{
// Pusta, celowo publiczna czesciowa deklaracja -- pozwala WebApplicationFactory<Program>
// (SameBeat.Tests/Integration) uruchomic te aplikacje w testach. Bez tego klasa Program
// wygenerowana z top-level statements jest internal i niewidoczna dla projektu testowego.
}