From 3a116f9baea4a05694a18572e2dc3cb842f74af7 Mon Sep 17 00:00:00 2001 From: Loic Masi Date: Tue, 21 Apr 2026 11:46:29 +0200 Subject: [PATCH] #1 : Test de Keycloak. --- .../Controllers/DashboardController.cs | 2 + .../Controllers/AccountController.cs | 49 ++++ .../Controllers/AccueilController.cs | 2 + .../Controllers/ArtisteController.cs | 2 + .../Controllers/ContactController.cs | 2 + .../Controllers/RechercheController.cs | 2 + .../Controllers/TitreController.cs | 2 + Webzine.WebApplication/Program.cs | 243 ++++++++++++++++-- .../Views/Account/AccessDenied.cshtml | 13 + .../Views/Account/Login.cshtml | 15 ++ .../Views/Account/Logout.cshtml | 15 ++ .../Views/Shared/_Layout.cshtml | 18 ++ .../Webzine.WebApplication.csproj | 6 +- .../appsettings.Development.json | 3 + Webzine.WebApplication/appsettings.json | 13 +- 15 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 Webzine.WebApplication/Controllers/AccountController.cs create mode 100644 Webzine.WebApplication/Views/Account/AccessDenied.cshtml create mode 100644 Webzine.WebApplication/Views/Account/Login.cshtml create mode 100644 Webzine.WebApplication/Views/Account/Logout.cshtml diff --git a/Webzine.WebApplication/Areas/Administration/Controllers/DashboardController.cs b/Webzine.WebApplication/Areas/Administration/Controllers/DashboardController.cs index 560533e..8309b42 100644 --- a/Webzine.WebApplication/Areas/Administration/Controllers/DashboardController.cs +++ b/Webzine.WebApplication/Areas/Administration/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ namespace Webzine.WebApplication.Areas.Administration.Controllers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Webzine.Business.Contracts; @@ -32,6 +33,7 @@ public class DashboardController : Controller /// Affiche le tableau de bord de l'administration. /// /// La vue Index du tableau de bord. + [Authorize(Roles = "ADMIN")] public IActionResult Index() { DashboardDTO data = this.dashboardService.GetDashboardData(); diff --git a/Webzine.WebApplication/Controllers/AccountController.cs b/Webzine.WebApplication/Controllers/AccountController.cs new file mode 100644 index 0000000..10fb868 --- /dev/null +++ b/Webzine.WebApplication/Controllers/AccountController.cs @@ -0,0 +1,49 @@ +namespace Webzine.WebApplication.Controllers +{ + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + using Microsoft.AspNetCore.Authentication.OpenIdConnect; + using Microsoft.AspNetCore.Mvc; + + public class AccountController : Controller + { + [HttpGet("/account/login")] + public IActionResult Login(string? returnUrl = "/") + { + return this.Challenge( + new AuthenticationProperties + { + RedirectUri = string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl, + }, + OpenIdConnectDefaults.AuthenticationScheme); + } + + [HttpGet("/account/logout")] + public IActionResult Logout() + { + return this.SignOut( + new AuthenticationProperties + { + RedirectUri = "/", + }, + CookieAuthenticationDefaults.AuthenticationScheme, + OpenIdConnectDefaults.AuthenticationScheme); + } + + [HttpGet("/account/access-denied")] + public IActionResult AccessDenied() + { + return this.View(); + } + + [HttpGet("/account/auth-error")] + public IActionResult AuthError(string? message = null) + { + this.ViewData["Message"] = string.IsNullOrWhiteSpace(message) + ? "Une erreur est survenue pendant la connexion." + : message; + + return this.View(); + } + } +} \ No newline at end of file diff --git a/Webzine.WebApplication/Controllers/AccueilController.cs b/Webzine.WebApplication/Controllers/AccueilController.cs index f7832da..7c3d15a 100644 --- a/Webzine.WebApplication/Controllers/AccueilController.cs +++ b/Webzine.WebApplication/Controllers/AccueilController.cs @@ -1,5 +1,6 @@ namespace Webzine.WebApplication.Controllers { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Webzine.Repository.Contracts; @@ -38,6 +39,7 @@ /// /// Le numéro de page pour la pagination des titres (par défaut à 0). /// La vue Index avec le ViewModel contenant les listes de titres à afficher. + [Authorize(Roles = "ADMIN")] public IActionResult Index(int page = 0) { this.logger.LogInformation("Arrivée sur la page d'accueil"); diff --git a/Webzine.WebApplication/Controllers/ArtisteController.cs b/Webzine.WebApplication/Controllers/ArtisteController.cs index dc053fd..01f83cb 100644 --- a/Webzine.WebApplication/Controllers/ArtisteController.cs +++ b/Webzine.WebApplication/Controllers/ArtisteController.cs @@ -1,5 +1,6 @@ namespace Webzine.WebApplication.Controllers { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Webzine.Repository.Contracts; @@ -33,6 +34,7 @@ /// /// Le nom de l'artiste à rechercher, formaté en kebab-case (ex: "fatal-bazooka"). /// La vue de l'artiste avec son ViewModel, ou une redirection vers l'accueil si le nom est vide, ou une erreur 404 si l'artiste n'est pas trouvé. + [Authorize(Roles = "ADMIN")] public IActionResult Index(string nom) { this.logger.LogInformation("Tentative d'accès à l'artiste avec le nom : {NomArtiste}", nom); diff --git a/Webzine.WebApplication/Controllers/ContactController.cs b/Webzine.WebApplication/Controllers/ContactController.cs index bda67de..bda6cbb 100644 --- a/Webzine.WebApplication/Controllers/ContactController.cs +++ b/Webzine.WebApplication/Controllers/ContactController.cs @@ -1,5 +1,6 @@ namespace Webzine.WebApplication.Controllers { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; /// @@ -25,6 +26,7 @@ /// Affiche la page de contact du webzine. /// /// La vue Index de la page de contact. + [Authorize(Roles = "ADMIN")] public IActionResult Index() { return this.View(); diff --git a/Webzine.WebApplication/Controllers/RechercheController.cs b/Webzine.WebApplication/Controllers/RechercheController.cs index be5b50f..b60fc5a 100644 --- a/Webzine.WebApplication/Controllers/RechercheController.cs +++ b/Webzine.WebApplication/Controllers/RechercheController.cs @@ -4,6 +4,7 @@ namespace Webzine.WebApplication.Controllers { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Webzine.Repository.Contracts; @@ -39,6 +40,7 @@ namespace Webzine.WebApplication.Controllers /// /// Nom d'artiste ou de titre. /// Page de recherche avec les r�sultats. + [Authorize(Roles = "ADMIN")] public IActionResult Index(string mot) { // Logger la recherche. diff --git a/Webzine.WebApplication/Controllers/TitreController.cs b/Webzine.WebApplication/Controllers/TitreController.cs index ecc6ddc..c6c904e 100644 --- a/Webzine.WebApplication/Controllers/TitreController.cs +++ b/Webzine.WebApplication/Controllers/TitreController.cs @@ -4,6 +4,7 @@ namespace Webzine.WebApplication.Controllers { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Webzine.Entity; @@ -47,6 +48,7 @@ namespace Webzine.WebApplication.Controllers /// Identifiant du titre. /// Model de donnée pour un commentaire. /// Vue des details ou 404 si introuvable. + [Authorize(Roles = "ADMIN")] public IActionResult Index(int id) { this.logger.LogInformation("Demande d'affichage du detail pour le titre ID {Id}.", id); diff --git a/Webzine.WebApplication/Program.cs b/Webzine.WebApplication/Program.cs index 4490aad..a1860fe 100644 --- a/Webzine.WebApplication/Program.cs +++ b/Webzine.WebApplication/Program.cs @@ -1,7 +1,16 @@ // 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; @@ -23,30 +32,223 @@ using Webzine.WebApplication.Interceptors; 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); - // Ajoute les services necessaires pour permettre l'utilisation des - // controllers avec des vues. 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.Filters.Add(); - options.Filters.Add(); - options.Filters.Add(); + 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; + }, + }; }); - // Ajoute les services necessaires pour permettre l'utilisation des controllers avec des vues. - builder.Services.AddControllersWithViews() - - // Ajoute la compilation des vues lors de l'execution de l'application. - // Cela nous evite de recompiler l'application a chaque modification de vue. - // Necessite le package Nuget Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation. - .AddRazorRuntimeCompilation(); + 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(); @@ -54,10 +256,10 @@ try { 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. - // En fonction de la configuration, utilise soit les repositories bases sur une base de donnees, soit les repositories bases sur des listes locales. var repositoryType = builder.Configuration.GetValue("Repository"); var seederType = builder.Configuration.GetValue("Seeder"); var shouldSeed = args.Contains("--seed"); @@ -104,12 +306,13 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); - // https://learn.microsoft.com/fr-fr/aspnet/core/performance/response-compression?view=aspnetcore-10.0#configuration - // Ajoute le service de compression des reponses HTTP pour reduire la taille des donnees envoyees au client et ameliorer les performances de l'application. 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(); if (repositoryType == RepositoryType.Db) @@ -168,32 +371,30 @@ try app.UseResponseCompression(); - // Active la possibilite de servir des fichiers statiques presents dans le dossier wwwroot. app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { - // https://learn.microsoft.com/fr-fr/aspnet/core/fundamentals/static-files?view=aspnetcore-10.0#set-http-response-headers ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000"); }, }); - // Active le middleware permettant le routage des requetes entrantes. app.UseRouting(); - // Appelle les routes definies dans le dossier Extensions. + app.UseAuthentication(); + app.UseAuthorization(); + app.MapCustomRoutes(); + app.MapControllers(); app.Run(); } catch (Exception exception) { - // NLog: attrape les exceptions non gerees et les logger. logger.Error(exception, "Stopped program because of exception"); throw; } finally { - // Assure que NLog flush tous les messages de log avant de fermer l'application. LogManager.Shutdown(); } \ No newline at end of file diff --git a/Webzine.WebApplication/Views/Account/AccessDenied.cshtml b/Webzine.WebApplication/Views/Account/AccessDenied.cshtml new file mode 100644 index 0000000..be9acf4 --- /dev/null +++ b/Webzine.WebApplication/Views/Account/AccessDenied.cshtml @@ -0,0 +1,13 @@ +@{ + ViewData["Title"] = "Accès refusé"; +} + +
+

Accès refusé

+ +

Vous n'avez pas les droits pour accéder à cette page.

+ + + Retour à l'accueil + +
\ No newline at end of file diff --git a/Webzine.WebApplication/Views/Account/Login.cshtml b/Webzine.WebApplication/Views/Account/Login.cshtml new file mode 100644 index 0000000..e92a6f0 --- /dev/null +++ b/Webzine.WebApplication/Views/Account/Login.cshtml @@ -0,0 +1,15 @@ +@{ + ViewData["Title"] = "Connexion"; +} + +
+

Connexion

+ +

Vous allez être redirigé vers le serveur d'authentification.

+ + + Se connecter avec Keycloak + +
\ No newline at end of file diff --git a/Webzine.WebApplication/Views/Account/Logout.cshtml b/Webzine.WebApplication/Views/Account/Logout.cshtml new file mode 100644 index 0000000..2501b34 --- /dev/null +++ b/Webzine.WebApplication/Views/Account/Logout.cshtml @@ -0,0 +1,15 @@ +@{ + ViewData["Title"] = "Déconnexion"; +} + +
+

Déconnexion

+ +

Vous êtes sur le point de vous déconnecter.

+ +
+ +
+
\ No newline at end of file diff --git a/Webzine.WebApplication/Views/Shared/_Layout.cshtml b/Webzine.WebApplication/Views/Shared/_Layout.cshtml index c4e734d..fc699a5 100644 --- a/Webzine.WebApplication/Views/Shared/_Layout.cshtml +++ b/Webzine.WebApplication/Views/Shared/_Layout.cshtml @@ -14,6 +14,24 @@ + @if (User.Identity?.IsAuthenticated == true) + { + Bonjour @User.Identity.Name + + + Déconnexion + + } + else + { + + Connexion + + }
diff --git a/Webzine.WebApplication/Webzine.WebApplication.csproj b/Webzine.WebApplication/Webzine.WebApplication.csproj index 9f96baf..b870e00 100644 --- a/Webzine.WebApplication/Webzine.WebApplication.csproj +++ b/Webzine.WebApplication/Webzine.WebApplication.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -27,6 +27,10 @@ + + + + diff --git a/Webzine.WebApplication/appsettings.Development.json b/Webzine.WebApplication/appsettings.Development.json index f458f85..78d39d3 100644 --- a/Webzine.WebApplication/appsettings.Development.json +++ b/Webzine.WebApplication/appsettings.Development.json @@ -4,5 +4,8 @@ "SpotifySeeder": { "ClientId": "", "ClientSecret": "" + }, + "Keycloak": { + "PublicOrigin": "https://localhost:7095" } } diff --git a/Webzine.WebApplication/appsettings.json b/Webzine.WebApplication/appsettings.json index a6cfb03..f743d51 100644 --- a/Webzine.WebApplication/appsettings.json +++ b/Webzine.WebApplication/appsettings.json @@ -26,13 +26,16 @@ "MaxCommentsPerTrack": 3 }, "Keycloak": { - "Authority": "https:///realms/", - "ClientId": "my-aspnet-app", - "ClientSecret": "your-client-secret", - "ResponseType": "code" + "Authority": "https://10.4.0.131/keycloak/realms/webzine-realm", + "PublicOrigin": "http://10.4.0.131:8080", + "ClientId": "webzine-client", + "ClientSecret": "Z9JgRucpeZD4jqRhTciiznX3PPoJ9oYp", + "ResponseType": "code", + "CallbackPath": "/signin-oidc", + "SignedOutCallbackPath": "/signout-callback-oidc" }, "AllowedHosts": "*", "EfPerformance": { "SeuilMs": 60 } -} \ No newline at end of file +}