400 lines
15 KiB
C#
400 lines
15 KiB
C#
// 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);
|
||
}
|
||
|
||
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";
|
||
|
||
options.Authority = builder.Configuration["Keycloak:Authority"];
|
||
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 = true;
|
||
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
|
||
{
|
||
OnRedirectToIdentityProvider = context =>
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(publicOrigin))
|
||
{
|
||
context.ProtocolMessage.RedirectUri = publicOrigin + context.Options.CallbackPath;
|
||
}
|
||
|
||
logger.Info("RedirectUri Keycloak envoyee : {RedirectUri}", context.ProtocolMessage.RedirectUri);
|
||
Console.WriteLine("RedirectUri envoyé à Keycloak : " + context.ProtocolMessage.RedirectUri);
|
||
Console.WriteLine("Authority utilisée : " + context.Options.Authority);
|
||
|
||
return Task.CompletedTask;
|
||
},
|
||
|
||
OnRedirectToIdentityProviderForSignOut = context =>
|
||
{
|
||
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;
|
||
|
||
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();
|
||
|
||
// 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();
|
||
} |