#1 : Test de Keycloak.

This commit is contained in:
Loic Masi
2026-04-21 11:46:29 +02:00
parent 4157991805
commit 3a116f9bae
15 changed files with 360 additions and 27 deletions

View File

@@ -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<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.Filters.Add<ValidationActionFilter>();
options.Filters.Add<GlobalExceptionFilter>();
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 lerreur "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<SpotifySeederOptions>(builder.Configuration.GetSection("SpotifySeeder"));
builder.Services.AddHttpClient<SeedDataSpotify>();
@@ -54,10 +256,10 @@ try
{
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.
// 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 seederType = builder.Configuration.GetValue<SeederType>("Seeder");
var shouldSeed = args.Contains("--seed");
@@ -104,12 +306,13 @@ try
builder.Services.AddScoped<ValidationActionFilter>();
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();
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)
@@ -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();
}