26 Commits

Author SHA1 Message Date
Loic Masi
90dc1af5ec #1 : Update token keycloak.
Some checks failed
Deploiement Test Keycloak / Build et Déploiement (push) Has been cancelled
2026-04-22 11:54:32 +02:00
Loic Masi
7fd3137156 #1 : Mis à jours des urls avec le bon port. 2026-04-22 11:27:02 +02:00
Loic Masi
76f8ec1cf5 #1 : Il a perdu s'est crampté. 2026-04-21 16:44:14 +02:00
Loic Masi
50ca65c239 #69 : Qui a tiré sur mon petit frère. 2026-04-21 16:38:32 +02:00
Loic Masi
3451f8228a #69 : L'eau elle est couleur Whatsapp. 2026-04-21 16:31:58 +02:00
Loic Masi
777ddb4069 #1 : J'effectue le dab, dans des endroits inadéquats. 2026-04-21 16:27:40 +02:00
Loic Masi
2c144a2b5f #1 : Tung Tung Tung Sahur. 2026-04-21 16:18:24 +02:00
Loic Masi
ab488843ac #1 : Jean Neymar de Keycloak. 2026-04-21 16:07:32 +02:00
Loic Masi
e08a73dbd5 #1 : MMais qui m'a mis un Ops pareil. 2026-04-21 15:59:17 +02:00
Loic Masi
c23f481080 #1 : Quitoque le chef lol. 2026-04-21 15:41:42 +02:00
Loic Masi
a24fa109d4 #1 : Ouai euhh, c'est michel, tu donnes pas de nouvelles on peut pas te faire confiance. 2026-04-21 15:33:09 +02:00
Loic Masi
05f3dd5462 #1 : Marina domine et tu t'inclines. 2026-04-21 15:25:03 +02:00
Loic Masi
560ac04985 #1 : Eeet Hop. Elle est pour Nicolas celle là. 2026-04-21 15:00:59 +02:00
Loic Masi
d5ca4dcb47 #1 : Encore, ça fait beaucoup la, non. 2026-04-21 14:45:20 +02:00
Loic Masi
265297519c #1 : Encore, ça fait beaucoup la, non. 2026-04-21 14:37:55 +02:00
Loic Masi
8be0047eee #1 : Changement appsettings. 2026-04-21 14:31:40 +02:00
Loic Masi
fd574b0b1f #1 : Test de modi. 2026-04-21 14:25:29 +02:00
Loic Masi
097f7b8a20 #1 : Patch bug. 2026-04-21 13:50:18 +02:00
Loic Masi
cd9da8a9a3 #1 : Modification log. 2026-04-21 13:43:43 +02:00
Loic Masi
2f563438a2 #1 : Nouveau test. 2026-04-21 13:39:37 +02:00
Loic Masi
6fd75d1d7f #1 : Test patch bug. 2026-04-21 13:35:00 +02:00
Loic Masi
c7a72eeea2 #1 : Modification droit et ip Keycloak. 2026-04-21 13:28:20 +02:00
Loic Masi
7056a30e60 #1 : Teeest. 2026-04-21 12:59:19 +02:00
Loic Masi
74ff359049 #1 : Gougougaga. 2026-04-21 11:55:31 +02:00
Loic Masi
3a116f9bae #1 : Test de Keycloak. 2026-04-21 11:46:29 +02:00
Loic Masi
4157991805 #1 : Test de déploiement nouveau act-runner. 2026-04-21 09:47:09 +02:00
20 changed files with 547 additions and 51 deletions

View File

@@ -0,0 +1,23 @@
name: Deploiement Test Keycloak
run-nale: ${{ gitea.actor }} déploie test keycloak.
on:
push:
branches:
- loic-masi/ajout-auth-keycloak
jobs:
Deploiement-Test-Keycloak:
name: Build et Déploiement
runs-on: test-keycloak
steps:
- name: Récupération du code source
uses: actions/checkout@v4
- name: Redémarrage Docker
run: |
echo "Démarrage du déploiement Docker"
docker compose down
docker compose up -d --build
echo "Déploiement terminé !"

View File

@@ -1,5 +1,6 @@
namespace Webzine.WebApplication.Areas.Administration.Controllers; namespace Webzine.WebApplication.Areas.Administration.Controllers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Webzine.Entity; using Webzine.Entity;
@@ -38,6 +39,7 @@ public class ArtisteController : Controller
/// </summary> /// </summary>
/// <param name="page">Le numéro de page pour la pagination des artistes (par défaut à 0).</param> /// <param name="page">Le numéro de page pour la pagination des artistes (par défaut à 0).</param>
/// <returns>Redirection.</returns> /// <returns>Redirection.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Index(int page = 0) public IActionResult Index(int page = 0)
{ {
int artistes_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration"); int artistes_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration");
@@ -93,6 +95,7 @@ public class ArtisteController : Controller
/// </summary> /// </summary>
/// <param name="id">L'identifiant de l'artiste à modifier. </param> /// <param name="id">L'identifiant de l'artiste à modifier. </param>
/// <returns>Redirection.</returns> /// <returns>Redirection.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Edit(int id) public IActionResult Edit(int id)
{ {
var artiste = this.artisteRepository.Find(id); var artiste = this.artisteRepository.Find(id);
@@ -110,6 +113,7 @@ public class ArtisteController : Controller
/// </summary> /// </summary>
/// <param name="model">Paramètre d'un artiste.</param> /// <param name="model">Paramètre d'un artiste.</param>
/// <returns>Redirection sur Index.</returns> /// <returns>Redirection sur Index.</returns>
[Authorize(Roles = "ADMIN")]
[HttpPost] [HttpPost]
public IActionResult Edit(ArtisteEditViewModel model) public IActionResult Edit(ArtisteEditViewModel model)
{ {
@@ -133,6 +137,7 @@ public class ArtisteController : Controller
/// </summary> /// </summary>
/// <param name="id">L'identifiant de l'artiste à supprimer. </param> /// <param name="id">L'identifiant de l'artiste à supprimer. </param>
/// <returns>Redirection.</returns> /// <returns>Redirection.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Delete(int id) public IActionResult Delete(int id)
{ {
var artiste = this.artisteRepository.Find(id); var artiste = this.artisteRepository.Find(id);
@@ -156,6 +161,7 @@ public class ArtisteController : Controller
/// </summary> /// </summary>
/// <param name="model">L'artiste à supprimer.</param> /// <param name="model">L'artiste à supprimer.</param>
/// <returns>Redirige vers la page d'index d'admin artiste.</returns> /// <returns>Redirige vers la page d'index d'admin artiste.</returns>
[Authorize(Roles = "ADMIN")]
[HttpPost] [HttpPost]
public IActionResult Delete(AdminArtisteForm model) public IActionResult Delete(AdminArtisteForm model)
{ {

View File

@@ -1,5 +1,6 @@
namespace Webzine.WebApplication.Areas.Administration.Controllers namespace Webzine.WebApplication.Areas.Administration.Controllers
{ {
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Webzine.Repository.Contracts; using Webzine.Repository.Contracts;
@@ -40,6 +41,7 @@ namespace Webzine.WebApplication.Areas.Administration.Controllers
/// </summary> /// </summary>
/// <param name="page">Le numéro de page pour la pagination des commentaires (par défaut à 0).</param> /// <param name="page">Le numéro de page pour la pagination des commentaires (par défaut à 0).</param>
/// <returns>La vue Index avec le ViewModel contenant la liste des commentaires.</returns> /// <returns>La vue Index avec le ViewModel contenant la liste des commentaires.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Index(int page = 0) public IActionResult Index(int page = 0)
{ {
int commentaires_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration"); int commentaires_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration");

View File

@@ -1,5 +1,6 @@
namespace Webzine.WebApplication.Areas.Administration.Controllers; namespace Webzine.WebApplication.Areas.Administration.Controllers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Webzine.Business.Contracts; using Webzine.Business.Contracts;
@@ -32,6 +33,7 @@ public class DashboardController : Controller
/// Affiche le tableau de bord de l'administration. /// Affiche le tableau de bord de l'administration.
/// </summary> /// </summary>
/// <returns>La vue Index du tableau de bord.</returns> /// <returns>La vue Index du tableau de bord.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Index() public IActionResult Index()
{ {
DashboardDTO data = this.dashboardService.GetDashboardData(); DashboardDTO data = this.dashboardService.GetDashboardData();

View File

@@ -1,5 +1,6 @@
namespace Webzine.WebApplication.Areas.Administration.Controllers namespace Webzine.WebApplication.Areas.Administration.Controllers
{ {
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Webzine.Entity; using Webzine.Entity;
@@ -40,6 +41,7 @@ namespace Webzine.WebApplication.Areas.Administration.Controllers
/// </summary> /// </summary>
/// <param name="page">Le numero de page pour la pagination des styles (par defaut a 0).</param> /// <param name="page">Le numero de page pour la pagination des styles (par defaut a 0).</param>
/// <returns>La vue Index avec la liste des styles.</returns> /// <returns>La vue Index avec la liste des styles.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Index(int page = 0) public IActionResult Index(int page = 0)
{ {
int styles_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration"); int styles_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration");

View File

@@ -2,6 +2,7 @@ namespace Webzine.WebApplication.Areas.Administration.Controllers;
using Business.Contracts; using Business.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@@ -54,6 +55,7 @@ public class TitreController : Controller
/// </summary> /// </summary>
/// <param name="page">Le numéro de page pour la pagination des titres (par défaut à 0).</param> /// <param name="page">Le numéro de page pour la pagination des titres (par défaut à 0).</param>
/// <returns>La vue Index avec le ViewModel contenant la liste des titres.</returns> /// <returns>La vue Index avec le ViewModel contenant la liste des titres.</returns>
[Authorize(Roles = "ADMIN")]
public IActionResult Index(int page = 0) public IActionResult Index(int page = 0)
{ {
int titres_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration"); int titres_par_page = this.configuration.GetValue<int>("Webzine:NombreDeLignesAdministration");

View File

@@ -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();
}
}
}

View File

@@ -38,7 +38,7 @@ namespace Webzine.WebApplication.Controllers
/// Affichage de la page Recherche depuis le header de l'app. /// Affichage de la page Recherche depuis le header de l'app.
/// </summary> /// </summary>
/// <param name="mot">Nom d'artiste ou de titre.</param> /// <param name="mot">Nom d'artiste ou de titre.</param>
/// <returns>Page de recherche avec les r<EFBFBD>sultats.</returns> /// <returns>Page de recherche avec les résultats.</returns>
public IActionResult Index(string mot) public IActionResult Index(string mot)
{ {
// Logger la recherche. // Logger la recherche.

View File

@@ -34,29 +34,51 @@
var methode = context.Request.Method; var methode = context.Request.Method;
var endpoint = context.Request.Path; var endpoint = context.Request.Path;
var traceId = context.TraceIdentifier; // Identifiant unique généré par .NET var traceId = context.TraceIdentifier; // Identifiant unique généré par .NET
var exceptionLevee = false;
this.logger.LogInformation("[IN] TraceId: {traceId} | Méthode: {methode} | Endpoint: {endpoint}", traceId, methode, endpoint); this.logger.LogInformation("[IN] TraceId: {traceId} | Méthode: {methode} | Endpoint: {endpoint}", traceId, methode, endpoint);
await this.next(context); try
// (Après le contrôleur)
chronometre.Stop(); // arrête le chrono
var tempsEcoule = chronometre.ElapsedMilliseconds;
var httpCode = context.Response.StatusCode; // exemple: 200, 404, 500
// --- OUT ---
if (httpCode >= 500)
{ {
this.logger.LogError("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint); await this.next(context);
} }
else if (httpCode >= 400) catch (Exception exception)
{ {
this.logger.LogWarning("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint); exceptionLevee = true;
chronometre.Stop();
this.logger.LogError(
exception,
"[EXCEPTION] TraceId: {traceId} | Temps: {tempsEcoule} ms | Endpoint: {endpoint} | Type: {exceptionType} | Message: {exceptionMessage}",
traceId,
chronometre.ElapsedMilliseconds,
endpoint,
exception.GetType().FullName,
exception.Message);
throw;
} }
else finally
{ {
this.logger.LogInformation("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint); if (chronometre.IsRunning)
{
chronometre.Stop();
}
var tempsEcoule = chronometre.ElapsedMilliseconds;
var httpCode = exceptionLevee ? StatusCodes.Status500InternalServerError : context.Response.StatusCode; // exemple: 200, 404, 500
// --- OUT ---
if (httpCode >= 500)
{
this.logger.LogError("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint);
}
else if (httpCode >= 400)
{
this.logger.LogWarning("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint);
}
else
{
this.logger.LogInformation("[OUT] TraceId: {traceId} | HTTP {httpCode} | Temps: {tempsEcoule} ms | Endpoint: {endpoint}", traceId, httpCode, tempsEcoule, endpoint);
}
} }
} }
} }

View File

@@ -1,7 +1,16 @@
// L'erreur SA1200 (ordre des using directives) est desactivee pour Program.cs // L'erreur SA1200 (ordre des using directives) est desactivee pour Program.cs
#pragma warning disable SA1200 #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.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using NLog; using NLog;
using NLog.Web; using NLog.Web;
@@ -23,30 +32,298 @@ using Webzine.WebApplication.Interceptors;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Debug("init main"); 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 try
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Ajoute les services necessaires pour permettre l'utilisation des
// controllers avec des vues.
builder.Services.AddControllersWithViews(options => 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.Filters.Add<GlobalExceptionFilter>(); options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Filters.Add<ValidationActionFilter>(); options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.Filters.Add<GlobalExceptionFilter>(); })
.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;
},
};
}); });
// Ajoute les services necessaires pour permettre l'utilisation des controllers avec des vues. builder.Services.AddAuthorization(options =>
builder.Services.AddControllersWithViews() {
options.AddPolicy("RequireUser", policy => policy.RequireRole("USER", "ADMIN"));
// Ajoute la compilation des vues lors de l'execution de l'application. options.AddPolicy("RequireAdmin", policy => policy.RequireRole("ADMIN"));
// Cela nous evite de recompiler l'application a chaque modification de vue. });
// Necessite le package Nuget Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation.
.AddRazorRuntimeCompilation();
// NLog: Setup NLog for Dependency injection // NLog: Setup NLog for Dependency injection
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Host.UseNLog(); builder.Host.UseNLog();
builder.Services.Configure<SpotifySeederOptions>(builder.Configuration.GetSection("SpotifySeeder")); builder.Services.Configure<SpotifySeederOptions>(builder.Configuration.GetSection("SpotifySeeder"));
builder.Services.AddHttpClient<SeedDataSpotify>(); builder.Services.AddHttpClient<SeedDataSpotify>();
@@ -54,10 +331,10 @@ try
{ {
options.SeuilMs = builder.Configuration.GetValue<int>("EfPerformance:SeuilMs"); options.SeuilMs = builder.Configuration.GetValue<int>("EfPerformance:SeuilMs");
}); });
builder.Services.AddSingleton<EfSlowQueryInterceptor>(); 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. // 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<RepositoryType>("Repository"); var repositoryType = builder.Configuration.GetValue<RepositoryType>("Repository");
var seederType = builder.Configuration.GetValue<SeederType>("Seeder"); var seederType = builder.Configuration.GetValue<SeederType>("Seeder");
var shouldSeed = args.Contains("--seed"); var shouldSeed = args.Contains("--seed");
@@ -104,11 +381,13 @@ try
builder.Services.AddScoped<ValidationActionFilter>(); builder.Services.AddScoped<ValidationActionFilter>();
builder.Services.AddScoped<GlobalExceptionFilter>(); builder.Services.AddScoped<GlobalExceptionFilter>();
// 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(); builder.Services.AddResponseCompression();
var app = builder.Build(); 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>(); app.UseMiddleware<Webzine.WebApplication.Middlewares.LogTempsExecutionMiddleware>();
@@ -168,32 +447,30 @@ try
app.UseResponseCompression(); app.UseResponseCompression();
// Active la possibilite de servir des fichiers statiques presents dans le dossier wwwroot.
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions
{ {
OnPrepareResponse = ctx => 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"); ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000");
}, },
}); });
// Active le middleware permettant le routage des requetes entrantes.
app.UseRouting(); app.UseRouting();
// Appelle les routes definies dans le dossier Extensions. app.UseAuthentication();
app.UseAuthorization();
app.MapCustomRoutes(); app.MapCustomRoutes();
app.MapControllers();
app.Run(); app.Run();
} }
catch (Exception exception) catch (Exception exception)
{ {
// NLog: attrape les exceptions non gerees et les logger.
logger.Error(exception, "Stopped program because of exception"); logger.Error(exception, "Stopped program because of exception");
throw; throw;
} }
finally finally
{ {
// Assure que NLog flush tous les messages de log avant de fermer l'application.
LogManager.Shutdown(); LogManager.Shutdown();
} }

View File

@@ -0,0 +1,13 @@
@{
ViewData["Title"] = "Accès refusé";
}
<div class="container mt-5 text-center">
<h2>Accès refusé</h2>
<p>Vous n'avez pas les droits pour accéder à cette page.</p>
<a href="/" class="btn btn-secondary">
Retour à l'accueil
</a>
</div>

View File

@@ -0,0 +1,19 @@
@{
ViewData["Title"] = "Erreur de connexion";
var message = ViewData["Message"]?.ToString();
}
<div class="container mt-5 text-center">
<h2>Erreur de connexion</h2>
<p>La connexion avec Keycloak n'a pas pu aboutir.</p>
@if (!string.IsNullOrWhiteSpace(message))
{
<pre class="alert alert-danger text-start">@message</pre>
}
<a href="/" class="btn btn-secondary">
Retour à l'accueil
</a>
</div>

View File

@@ -0,0 +1,15 @@
@{
ViewData["Title"] = "Connexion";
}
<div class="container mt-5 text-center">
<h2>Connexion</h2>
<p>Vous allez être redirigé vers le serveur d'authentification.</p>
<a asp-controller="Account"
asp-action="Login"
class="btn btn-primary">
Se connecter avec Keycloak
</a>
</div>

View File

@@ -0,0 +1,15 @@
@{
ViewData["Title"] = "Déconnexion";
}
<div class="container mt-5 text-center">
<h2>Déconnexion</h2>
<p>Vous êtes sur le point de vous déconnecter.</p>
<form asp-controller="Account" asp-action="Logout" method="get">
<button type="submit" class="btn btn-danger">
Se déconnecter
</button>
</form>
</div>

View File

@@ -58,6 +58,27 @@
</a> </a>
</li> </li>
<li class="nav-item">
@if (User.Identity?.IsAuthenticated == true)
{
<span class="text-primary">Bonjour @User.Identity.Name</span>
<a asp-controller="Account"
asp-action="Logout"
class="btn btn-outline-danger btn-sm">
Déconnexion
</a>
}
else
{
<a asp-controller="Account"
asp-action="Login"
class="btn btn-outline-primary btn-sm">
Connexion
</a>
}
</li>
</ul> </ul>
<!-- Barre de recherche --> <!-- Barre de recherche -->

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -22,9 +22,15 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Faker.Net" Version="2.0.163" /> <PackageReference Include="Faker.Net" Version="2.0.163" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.17.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.17.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.17.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.*" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.*" />
<PackageReference Include="NLog" Version="6.1.1" /> <PackageReference Include="NLog" Version="6.1.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118"> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118">

View File

@@ -1,8 +1,16 @@
{ {
"Seeder": "Local", "Seeder": "Local",
"Repository": "Db", "Repository": "Local",
"SpotifySeeder": { "SpotifySeeder": {
"ClientId": "", "ClientId": "",
"ClientSecret": "" "ClientSecret": ""
},
"Keycloak": {
"Authority": "https://10.4.0.131:8443/keycloak/realms/webzine-realm",
"MetadataAddress": "http://10.4.0.131:8080/keycloak/realms/webzine-realm/.well-known/openid-configuration",
"ClientId": "webzine-client",
"ClientSecret": "EEUiJ5mBmuOSElwnbNZeajLuw6yOyc8E",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath": "/signout-callback-oidc"
} }
} }

View File

@@ -1,8 +1,13 @@
{ {
"Seeder": "Spotify", "Seeder": "Local",
"Repository": "Db", "Repository": "Local",
"SpotifySeeder": { "SpotifySeeder": {
"ClientId": "390689c2fc79408b830d2f518375ef84", "ClientId": "",
"ClientSecret": "6e98a09c77ad43ae93bc0f0560cfcbe1" "ClientSecret": ""
},
"Keycloak": {
"Authority": "https://10.4.0.131:8443/keycloak/realms/webzine-realm",
"MetadataAddress": "https://10.4.0.131:8443/keycloak/realms/webzine-realm/.well-known/openid-configuration",
"PublicOrigin": "https://10.4.0.131:8443"
} }
} }

View File

@@ -13,7 +13,7 @@
}, },
"ConnectionStrings": { "ConnectionStrings": {
"SqliteConnection": "Data Source=Data/webzine.sqlite", "SqliteConnection": "Data Source=Data/webzine.sqlite",
"PostGreSQLConnection": "Host=localhost;Port=5432;Username=admin;Password=admin123;Database=webzine_db" "PostGreSQLConnection": ""
}, },
"SpotifySeeder": { "SpotifySeeder": {
"ClientId": "", "ClientId": "",
@@ -25,8 +25,17 @@
"TracksPerAlbum": 40, "TracksPerAlbum": 40,
"MaxCommentsPerTrack": 3 "MaxCommentsPerTrack": 3
}, },
"Keycloak": {
"Authority": "https://10.4.0.131:8443/keycloak/realms/webzine-realm",
"PublicOrigin": "https://10.4.0.131:8443",
"ClientId": "webzine-client",
"ClientSecret": "EEUiJ5mBmuOSElwnbNZeajLuw6yOyc8E",
"ResponseType": "code",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath": "/signout-callback-oidc"
},
"AllowedHosts": "*", "AllowedHosts": "*",
"EfPerformance": { "EfPerformance": {
"SeuilMs": 60 "SeuilMs": 60
} }
} }

View File

@@ -23,7 +23,7 @@
<!-- Console pour debug immédiat --> <!-- Console pour debug immédiat -->
<target xsi:type="Console" name="console" <target xsi:type="Console" name="console"
layout="${longdate}|${level:uppercase=true}|${logger}|${message}" /> layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}" />
</targets> </targets>
<rules> <rules>
@@ -32,10 +32,10 @@
<logger name="Webzine.Repository.*" minlevel="Debug" writeTo="allfile,ownfile-web,console" /> <logger name="Webzine.Repository.*" minlevel="Debug" writeTo="allfile,ownfile-web,console" />
<!-- Logs Microsoft en Warning+ sauf Hosting.Lifetime --> <!-- Logs Microsoft en Warning+ sauf Hosting.Lifetime -->
<logger name="Microsoft.*" minlevel="Warn" writeTo="allfile" final="true" /> <logger name="Microsoft.*" minlevel="Warn" writeTo="allfile,console" final="true" />
<logger name="Microsoft.Hosting.Lifetime*" minlevel="Info" writeTo="allfile,console" final="true" /> <logger name="Microsoft.Hosting.Lifetime*" minlevel="Info" writeTo="allfile,console" final="true" />
<!-- Tous les autres logs (y compris System) en Info+ --> <!-- Tous les autres logs (y compris System) en Info+ -->
<logger name="*" minlevel="Info" writeTo="allfile" /> <logger name="*" minlevel="Info" writeTo="allfile,console" />
</rules> </rules>
</nlog> </nlog>