// 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(); options.Filters.Add(); }) .AddRazorRuntimeCompilation(); JwtSecurityTokenHandler.DefaultMapInboundClaims = false; // Important derrière Nginx pour que ASP.NET Core comprenne bien : // - le host public // - le scheme https builder.Services.Configure(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 l’erreur "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(builder.Configuration.GetSection("SpotifySeeder")); builder.Services.AddHttpClient(); builder.Services.Configure(options => { options.SeuilMs = builder.Configuration.GetValue("EfPerformance:SeuilMs"); }); builder.Services.AddSingleton(); // 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("Repository"); var seederType = builder.Configuration.GetValue("Seeder"); var shouldSeed = args.Contains("--seed"); if (repositoryType == RepositoryType.Db) { if (builder.Environment.IsProduction()) { builder.Services.AddDbContext((serviceProvider, options) => { options .UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection")) .AddInterceptors(serviceProvider.GetRequiredService()); }); } else { builder.Services.AddDbContext((serviceProvider, options) => { options .UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection")) .AddInterceptors(serviceProvider.GetRequiredService()); }); } builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } else { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); } builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); 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(); if (repositoryType == RepositoryType.Db) { using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); if (shouldSeed) { db.Database.EnsureDeleted(); db.Database.EnsureCreated(); var repo = scope.ServiceProvider.GetRequiredService(); if (seederType == SeederType.Local) { repo.SeedBaseDeDonnees(nbArtistes: 1000, nbTitres: 50000, maxStyles: 50); } else if (seederType == SeederType.Spotify) { var spotifySeeder = scope.ServiceProvider.GetRequiredService(); var jeuDeDonnees = await spotifySeeder.GenererJeuDeDonneesAsync(); repo.SeedBaseDeDonnees(jeuDeDonnees); } } } } else { using (var scope = app.Services.CreateScope()) { var store = scope.ServiceProvider.GetRequiredService(); var artistes = SeedDataLocal.GenererListeArtiste(100); var styles = SeedDataLocal.GenererListeStyle(15, 20); var albums = SeedDataLocal.GenererListeAlbums(50); var commentaires = new List(); 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(); }