Files
webzine/Webzine.WebApplication/Program.cs
2026-04-21 16:44:14 +02:00

476 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// L'erreur SA1200 (ordre des using directives) est desactivee pour Program.cs
#pragma warning disable SA1200
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using NLog;
using NLog.Web;
using Webzine.Business;
using Webzine.Business.Contracts;
using Webzine.Business.Seeders;
using Webzine.EntitiesContext;
using Webzine.Entity;
using Webzine.Entity.Fixtures;
using Webzine.Repository;
using Webzine.Repository.Contracts;
using Webzine.WebApplication.Configuration;
using Webzine.WebApplication.Extensions;
using Webzine.WebApplication.Filters;
using Webzine.WebApplication.Interceptors;
// Initiation du logger NLog pour la classe courante afin de pouvoir l'utiliser pour logger des messages d'information, d'erreur, etc avant la construction de l'application.
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Debug("init main");
static void AddRoleClaim(ClaimsIdentity identity, string? roleValue)
{
if (!string.IsNullOrWhiteSpace(roleValue) &&
!identity.HasClaim(claim => claim.Type == "role" && claim.Value == roleValue))
{
identity.AddClaim(new Claim("role", roleValue));
}
}
static void AddKeycloakRolesFromJson(ClaimsIdentity identity, string? json, string? clientId)
{
if (string.IsNullOrWhiteSpace(json))
{
return;
}
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("realm_access", out var realmAccessElement) &&
realmAccessElement.TryGetProperty("roles", out var realmRolesElement))
{
foreach (var role in realmRolesElement.EnumerateArray())
{
AddRoleClaim(identity, role.GetString());
}
}
if (!string.IsNullOrWhiteSpace(clientId) &&
document.RootElement.TryGetProperty("resource_access", out var resourceAccessElement) &&
resourceAccessElement.TryGetProperty(clientId, out var clientAccessElement) &&
clientAccessElement.TryGetProperty("roles", out var clientRolesElement))
{
foreach (var role in clientRolesElement.EnumerateArray())
{
AddRoleClaim(identity, role.GetString());
}
}
}
static void AddKeycloakRolesFromClaims(ClaimsIdentity identity, ClaimsPrincipal principal, string? clientId)
{
var realmAccessClaim = principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrWhiteSpace(realmAccessClaim))
{
AddKeycloakRolesFromJson(identity, $$"""{ "realm_access": {{realmAccessClaim}} }""", clientId);
}
var resourceAccessClaim = principal.FindFirst("resource_access")?.Value;
if (!string.IsNullOrWhiteSpace(resourceAccessClaim))
{
AddKeycloakRolesFromJson(identity, $$"""{ "resource_access": {{resourceAccessClaim}} }""", clientId);
}
}
static void AddKeycloakRolesFromAccessToken(ClaimsIdentity identity, string? accessToken, string? clientId)
{
if (string.IsNullOrWhiteSpace(accessToken))
{
return;
}
var token = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
var payload = JsonSerializer.Serialize(token.Payload);
AddKeycloakRolesFromJson(identity, payload, clientId);
}
static async Task LogKeycloakMetadataAsync(IConfiguration configuration, Logger logger)
{
var metadataAddress = configuration["Keycloak:MetadataAddress"];
if (string.IsNullOrWhiteSpace(metadataAddress))
{
var authority = configuration["Keycloak:Authority"]?.TrimEnd('/');
metadataAddress = string.IsNullOrWhiteSpace(authority)
? null
: authority + "/.well-known/openid-configuration";
}
if (string.IsNullOrWhiteSpace(metadataAddress))
{
logger.Warn("Diagnostic Keycloak ignore : aucune adresse de metadata configuree.");
return;
}
try
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var httpClient = new HttpClient(handler);
using var response = await httpClient.GetAsync(metadataAddress);
var content = await response.Content.ReadAsStringAsync();
var preview = content.Length > 500 ? content[..500] : content;
logger.Info(
"Diagnostic Keycloak metadata | Url: {MetadataAddress} | Status: {StatusCode} | ContentType: {ContentType} | Body: {BodyPreview}",
metadataAddress,
(int)response.StatusCode,
response.Content.Headers.ContentType?.ToString(),
preview.Replace(Environment.NewLine, " "));
Console.WriteLine(
"Diagnostic Keycloak metadata | Url: " + metadataAddress +
" | Status: " + (int)response.StatusCode +
" | ContentType: " + response.Content.Headers.ContentType +
" | Body: " + preview.Replace(Environment.NewLine, " "));
}
catch (Exception exception)
{
logger.Error(exception, "Diagnostic Keycloak metadata impossible | Url: {MetadataAddress} | Message: {Message}", metadataAddress, exception.Message);
Console.WriteLine("Diagnostic Keycloak metadata impossible | Url: " + metadataAddress + " | Message: " + exception.Message);
}
}
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add<ValidationActionFilter>();
options.Filters.Add<GlobalExceptionFilter>();
})
.AddRazorRuntimeCompilation();
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
// Important derrière Nginx pour que ASP.NET Core comprenne bien :
// - le host public
// - le scheme https
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.AccessDeniedPath = "/account/access-denied";
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
var publicOrigin = builder.Configuration["Keycloak:PublicOrigin"]?.TrimEnd('/');
var callbackPath = builder.Configuration["Keycloak:CallbackPath"] ?? "/signin-oidc";
var signedOutCallbackPath = builder.Configuration["Keycloak:SignedOutCallbackPath"] ?? "/signout-callback-oidc";
var metadataAddress = builder.Configuration["Keycloak:MetadataAddress"];
options.Authority = builder.Configuration["Keycloak:Authority"];
if (!string.IsNullOrWhiteSpace(metadataAddress))
{
options.MetadataAddress = metadataAddress;
}
options.ClientId = builder.Configuration["Keycloak:ClientId"];
options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.CallbackPath = callbackPath;
options.SignedOutCallbackPath = signedOutCallbackPath;
// Reverse proxy + certificat autosigné
options.RequireHttpsMetadata = false;
// Désactive PAR pour éviter lerreur "Invalid parameter: redirect_uri"
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Disable;
options.SaveTokens = false;
options.GetClaimsFromUserInfoEndpoint = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "preferred_username",
RoleClaimType = "role",
};
// Temporaire pour environnement de test avec certificat autosigné
options.BackchannelHttpHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
options.Events = new OpenIdConnectEvents
{
OnAuthenticationFailed = context =>
{
logger.Error(context.Exception, "Erreur d'authentification OIDC : {Message}", context.Exception.Message);
context.HandleResponse();
context.Response.Redirect("/account/auth-error?message=" + Uri.EscapeDataString(context.Exception.Message));
return Task.CompletedTask;
},
OnRedirectToIdentityProvider = context =>
{
if (!string.IsNullOrWhiteSpace(publicOrigin))
{
context.ProtocolMessage.RedirectUri = publicOrigin + context.Options.CallbackPath;
}
logger.Info("RedirectUri Keycloak envoyee : {RedirectUri}", context.ProtocolMessage.RedirectUri);
logger.Info("MetadataAddress Keycloak utilisee : {MetadataAddress}", context.Options.MetadataAddress);
Console.WriteLine("RedirectUri envoyé à Keycloak : " + context.ProtocolMessage.RedirectUri);
Console.WriteLine("Authority utilisée : " + context.Options.Authority);
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
var idToken = context.HttpContext.User.FindFirst("id_token")?.Value;
if (!string.IsNullOrWhiteSpace(idToken))
{
context.ProtocolMessage.IdTokenHint = idToken;
}
if (!string.IsNullOrWhiteSpace(publicOrigin))
{
context.ProtocolMessage.PostLogoutRedirectUri = publicOrigin + context.Options.SignedOutCallbackPath;
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var identity = (ClaimsIdentity)context.Principal!.Identity!;
var clientId = context.Options.ClientId;
if (context.SecurityToken is JwtSecurityToken idToken &&
!string.IsNullOrWhiteSpace(idToken.RawData))
{
identity.AddClaim(new Claim("id_token", idToken.RawData));
}
AddKeycloakRolesFromClaims(identity, context.Principal, clientId);
AddKeycloakRolesFromAccessToken(identity, context.TokenEndpointResponse?.AccessToken, clientId);
logger.Info(
"Roles Keycloak ajoutes a l'utilisateur {User}: {Roles}",
identity.Name,
string.Join(", ", identity.FindAll("role").Select(claim => claim.Value)));
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
var remoteError = context.Request.Query["error"].ToString();
var remoteDescription = context.Request.Query["error_description"].ToString();
var failureMessage = context.Failure?.Message;
var message = string.Join(
Environment.NewLine,
new[] { remoteError, remoteDescription, failureMessage }.Where(value => !string.IsNullOrWhiteSpace(value)));
logger.Error(context.Failure, "Erreur OIDC distante : {Message}", message);
Console.WriteLine("Erreur OIDC distante : " + message);
context.HandleResponse();
context.Response.Redirect("/account/auth-error?message=" + Uri.EscapeDataString(message));
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireUser", policy => policy.RequireRole("USER", "ADMIN"));
options.AddPolicy("RequireAdmin", policy => policy.RequireRole("ADMIN"));
});
// NLog: Setup NLog for Dependency injection
builder.Logging.ClearProviders();
builder.Host.UseNLog();
builder.Services.Configure<SpotifySeederOptions>(builder.Configuration.GetSection("SpotifySeeder"));
builder.Services.AddHttpClient<SeedDataSpotify>();
builder.Services.Configure<EfPerformanceOptions>(options =>
{
options.SeuilMs = builder.Configuration.GetValue<int>("EfPerformance:SeuilMs");
});
builder.Services.AddSingleton<EfSlowQueryInterceptor>();
// En fonction de la configuration, utilise soit les repositories basés sur une base de données, soit les repositories basés sur des listes locales.
var repositoryType = builder.Configuration.GetValue<RepositoryType>("Repository");
var seederType = builder.Configuration.GetValue<SeederType>("Seeder");
var shouldSeed = args.Contains("--seed");
if (repositoryType == RepositoryType.Db)
{
if (builder.Environment.IsProduction())
{
builder.Services.AddDbContext<WebzineDbContext>((serviceProvider, options) =>
{
options
.UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection"))
.AddInterceptors(serviceProvider.GetRequiredService<EfSlowQueryInterceptor>());
});
}
else
{
builder.Services.AddDbContext<WebzineDbContext>((serviceProvider, options) =>
{
options
.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection"))
.AddInterceptors(serviceProvider.GetRequiredService<EfSlowQueryInterceptor>());
});
}
builder.Services.AddScoped<DbEntityRepository>();
builder.Services.AddScoped<ITitreRepository, DbTitreRepository>();
builder.Services.AddScoped<IStyleRepository, DbStyleRepository>();
builder.Services.AddScoped<IArtisteRepository, DbArtisteRepository>();
builder.Services.AddScoped<ICommentaireRepository, DbCommentaireRepository>();
}
else
{
builder.Services.AddScoped<ITitreRepository, LocalTitreRepository>();
builder.Services.AddScoped<IStyleRepository, LocalStyleRepository>();
builder.Services.AddScoped<IArtisteRepository, LocalArtisteRepository>();
builder.Services.AddScoped<ICommentaireRepository, LocalCommentaireRepository>();
builder.Services.AddSingleton<InMemoryDataStore>();
}
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<ITitreAdminService, TitreAdminService>();
builder.Services.AddScoped<ValidationActionFilter>();
builder.Services.AddScoped<GlobalExceptionFilter>();
builder.Services.AddResponseCompression();
var app = builder.Build();
await LogKeycloakMetadataAsync(builder.Configuration, logger);
// Très important avant tout middleware qui lit le scheme/host de la requête.
app.UseForwardedHeaders();
app.UseMiddleware<Webzine.WebApplication.Middlewares.LogTempsExecutionMiddleware>();
if (repositoryType == RepositoryType.Db)
{
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<WebzineDbContext>();
db.Database.EnsureCreated();
if (shouldSeed)
{
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
var repo = scope.ServiceProvider.GetRequiredService<DbEntityRepository>();
if (seederType == SeederType.Local)
{
repo.SeedBaseDeDonnees(nbArtistes: 1000, nbTitres: 50000, maxStyles: 50);
}
else if (seederType == SeederType.Spotify)
{
var spotifySeeder = scope.ServiceProvider.GetRequiredService<SeedDataSpotify>();
var jeuDeDonnees = await spotifySeeder.GenererJeuDeDonneesAsync();
repo.SeedBaseDeDonnees(jeuDeDonnees);
}
}
}
}
else
{
using (var scope = app.Services.CreateScope())
{
var store = scope.ServiceProvider.GetRequiredService<InMemoryDataStore>();
var artistes = SeedDataLocal.GenererListeArtiste(100);
var styles = SeedDataLocal.GenererListeStyle(15, 20);
var albums = SeedDataLocal.GenererListeAlbums(50);
var commentaires = new List<Commentaire>();
var titres = SeedDataLocal.GenererListeTitre(500, artistes, styles, albums);
int commentaireIdStart = 1;
foreach (var titre in titres)
{
var commentairesForTitre = SeedDataLocal.GenererListeCommentaire(titre, 0, 5, commentaireIdStart);
titre.Commentaires.AddRange(commentairesForTitre);
commentaires.AddRange(commentairesForTitre);
commentaireIdStart += commentairesForTitre.Count;
}
store.Artistes.AddRange(artistes);
store.Styles = styles;
store.Titres = titres;
store.Commentaires.AddRange(commentaires);
}
}
app.UseResponseCompression();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000");
},
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapCustomRoutes();
app.MapControllers();
app.Run();
}
catch (Exception exception)
{
logger.Error(exception, "Stopped program because of exception");
throw;
}
finally
{
LogManager.Shutdown();
}