Mise en place de l'authentification Keycloak

This commit is contained in:
2026-04-13 22:33:56 +02:00
parent 70f693d85b
commit 6202b8b829
25 changed files with 531 additions and 21 deletions

View File

@@ -1,5 +1,20 @@
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
</Found>
</Router>
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<Authorizing>
<main class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<p class="eyebrow">Authentification</p>
<h1>Verification de la session en cours...</h1>
</section>
</main>
</Authorizing>
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
</ItemGroup>

View File

@@ -0,0 +1,13 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
var returnUrl = string.IsNullOrWhiteSpace(relativePath)
? "/"
: relativePath.StartsWith("/", StringComparison.Ordinal) ? relativePath : $"/{relativePath}";
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
}

View File

@@ -0,0 +1,85 @@
@using System.Security.Claims
@inject NavigationManager Navigation
<AuthorizeView>
<Authorized Context="authState">
<div class="user-access-bar">
<div class="user-access-copy">
<span class="micro-label">Compte Keycloak</span>
<strong>@BuildDisplayName(authState.User)</strong>
<span class="user-access-meta">@BuildMeta(authState.User)</span>
</div>
<div class="user-access-actions">
<a class="button ghost small" href="@LogoutHref">Se deconnecter</a>
</div>
</div>
</Authorized>
<NotAuthorized>
<div class="user-access-bar">
<div class="user-access-copy">
<span class="micro-label">Compte Keycloak</span>
<strong>Connexion requise pour lancer et reprendre les matchs</strong>
<span class="user-access-meta">Chaque compte conserve son propre etat de match dans ce navigateur.</span>
</div>
<div class="user-access-actions">
<a class="button primary small" href="@LoginHref">Se connecter</a>
</div>
</div>
</NotAuthorized>
</AuthorizeView>
@code {
private string LoginHref => BuildAuthHref("login", CurrentReturnUrl);
private string LogoutHref => BuildAuthHref("logout", "/");
private string CurrentReturnUrl
{
get
{
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
if (string.IsNullOrWhiteSpace(relativePath))
{
return "/";
}
return relativePath.StartsWith("/", StringComparison.Ordinal)
? relativePath
: $"/{relativePath}";
}
}
private static string BuildAuthHref(string action, string returnUrl)
=> $"authentication/{action}?returnUrl={Uri.EscapeDataString(returnUrl)}";
private static string BuildDisplayName(ClaimsPrincipal user)
=> user.Identity?.Name
?? user.FindFirst("name")?.Value
?? user.FindFirst("preferred_username")?.Value
?? "Utilisateur connecte";
private static string BuildMeta(ClaimsPrincipal user)
{
var details = new List<string>();
var email = user.FindFirst("email")?.Value;
if (!string.IsNullOrWhiteSpace(email))
{
details.Add(email);
}
var roles = user.FindAll("role")
.Select(claim => claim.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (roles.Length > 0)
{
details.Add($"Roles : {string.Join(", ", roles)}");
}
return details.Count > 0
? string.Join(" | ", details)
: "Session authentifiee via Keycloak.";
}
}

View File

@@ -1,5 +1,6 @@
@page "/application"
@page "/application.html"
@attribute [Authorize]
@inject BrowserBridge Browser
@inject MatchStore Store
@inject NavigationManager Navigation
@@ -23,6 +24,7 @@
<a class="button ghost" href="index.html">Accueil du site</a>
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
</div>
<UserAccessBar />
</div>
<aside class="hero-preview">

View File

@@ -0,0 +1,13 @@
@page "/authentication/{action}"
<main class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<p class="eyebrow">Authentification</p>
<RemoteAuthenticatorView Action="@Action" />
</section>
</main>
@code {
[Parameter]
public string? Action { get; set; }
}

View File

@@ -1,5 +1,6 @@
@page "/chrono"
@page "/chrono.html"
@attribute [Authorize]
@implements IAsyncDisposable
@inject MatchStore Store
@inject NavigationManager Navigation

View File

@@ -1,5 +1,6 @@
@page "/cube"
@page "/cube.html"
@attribute [Authorize]
@implements IAsyncDisposable
@inject BrowserBridge Browser
@inject MatchStore Store

View File

@@ -28,6 +28,7 @@
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
</div>
<UserAccessBar />
</div>
<aside class="hero-preview">

View File

@@ -32,6 +32,7 @@
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="index.html">Retour a l'accueil</a>
</div>
<UserAccessBar />
</div>
<aside class="hero-preview">

View File

@@ -2,13 +2,57 @@ using ChessCubing.App;
using ChessCubing.App.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? "/auth/realms/chesscubing";
var keycloakClientId = builder.Configuration["Keycloak:ClientId"] ?? "chesscubing-web";
var keycloakResponseType = builder.Configuration["Keycloak:ResponseType"] ?? "code";
var postLogoutRedirectUri = builder.Configuration["Keycloak:PostLogoutRedirectUri"] ?? "/";
var defaultScopes = builder.Configuration
.GetSection("Keycloak:DefaultScopes")
.GetChildren()
.Select(child => child.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.ToArray();
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services
.AddOidcAuthentication(options =>
{
options.ProviderOptions.Authority = ResolveUri(builder.HostEnvironment.BaseAddress, keycloakAuthority);
options.ProviderOptions.ClientId = keycloakClientId;
options.ProviderOptions.ResponseType = keycloakResponseType;
options.ProviderOptions.RedirectUri = ResolveUri(builder.HostEnvironment.BaseAddress, "authentication/login-callback");
options.ProviderOptions.PostLogoutRedirectUri = ResolveUri(builder.HostEnvironment.BaseAddress, postLogoutRedirectUri);
options.ProviderOptions.DefaultScopes.Clear();
foreach (var scope in defaultScopes.Length == 0 ? ["openid", "profile", "email"] : defaultScopes)
{
options.ProviderOptions.DefaultScopes.Add(scope);
}
options.UserOptions.NameClaim = "preferred_username";
options.UserOptions.RoleClaim = "role";
})
.AddAccountClaimsPrincipalFactory<KeycloakAccountFactory>();
builder.Services.AddScoped<BrowserBridge>();
builder.Services.AddScoped<UserSession>();
builder.Services.AddScoped<MatchStore>();
await builder.Build().RunAsync();
static string ResolveUri(string baseAddress, string value)
{
if (Uri.TryCreate(value, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.ToString();
}
return new Uri(new Uri(baseAddress), value).ToString();
}

View File

@@ -10,14 +10,14 @@ public sealed class BrowserBridge(IJSRuntime jsRuntime)
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
public ValueTask<string?> ReadMatchJsonAsync()
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey);
public ValueTask<string?> ReadMatchJsonAsync(string storageKey, string windowNameKey)
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", storageKey, windowNameKey);
public ValueTask WriteMatchJsonAsync(string json)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey, json);
public ValueTask WriteMatchJsonAsync(string storageKey, string windowNameKey, string json)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", storageKey, windowNameKey, json);
public ValueTask ClearMatchAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", MatchStore.StorageKey);
public ValueTask ClearMatchAsync(string storageKey, string windowNameKey)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", storageKey, windowNameKey);
public ValueTask<bool> PlayCubePhaseAlertAsync()
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");

View File

@@ -0,0 +1,53 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
namespace ChessCubing.App.Services;
public sealed class KeycloakAccountFactory(IAccessTokenProviderAccessor accessor)
: AccountClaimsPrincipalFactory<RemoteUserAccount>(accessor)
{
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
RemoteUserAccount account,
RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account, options);
if (user.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
{
return user;
}
var roleClaimType = options.RoleClaim ?? "role";
var existingRoles = identity.FindAll(roleClaimType)
.Select(claim => claim.Value)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var role in ReadRealmRoles(account))
{
if (existingRoles.Add(role))
{
identity.AddClaim(new Claim(roleClaimType, role));
}
}
return user;
}
private static IEnumerable<string> ReadRealmRoles(RemoteUserAccount account)
{
if (!account.AdditionalProperties.TryGetValue("realm_access", out var realmAccessValue) ||
realmAccessValue is not JsonElement realmAccessElement ||
realmAccessElement.ValueKind != JsonValueKind.Object ||
!realmAccessElement.TryGetProperty("roles", out var rolesElement) ||
rolesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return rolesElement.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.OfType<string>();
}
}

View File

@@ -4,10 +4,10 @@ using ChessCubing.App.Models;
namespace ChessCubing.App.Services;
public sealed class MatchStore(BrowserBridge browser)
public sealed class MatchStore(BrowserBridge browser, UserSession userSession)
{
public const string StorageKey = "chesscubing-arena-state-v2";
public const string WindowNameKey = "chesscubing-arena-state-v2:";
public const string StorageKeyPrefix = "chesscubing-arena-state-v3";
public const string WindowNameKeyPrefix = "chesscubing-arena-state-v3";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
@@ -16,6 +16,8 @@ public sealed class MatchStore(BrowserBridge browser)
private bool _dirty;
private long _lastPersistedAt;
private string? _activeStorageKey;
private string? _activeWindowNameKey;
public MatchState? Current { get; private set; }
@@ -23,6 +25,7 @@ public sealed class MatchStore(BrowserBridge browser)
public async Task EnsureLoadedAsync()
{
var storageScope = await SyncStorageScopeAsync();
if (IsLoaded)
{
return;
@@ -30,7 +33,7 @@ public sealed class MatchStore(BrowserBridge browser)
try
{
var raw = await browser.ReadMatchJsonAsync();
var raw = await browser.ReadMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey);
if (!string.IsNullOrWhiteSpace(raw))
{
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
@@ -61,6 +64,7 @@ public sealed class MatchStore(BrowserBridge browser)
public async Task SaveAsync()
{
var storageScope = await SyncStorageScopeAsync();
if (!IsLoaded)
{
return;
@@ -68,14 +72,14 @@ public sealed class MatchStore(BrowserBridge browser)
if (Current is null)
{
await browser.ClearMatchAsync();
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
return;
}
var json = JsonSerializer.Serialize(Current, JsonOptions);
await browser.WriteMatchJsonAsync(json);
await browser.WriteMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey, json);
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
}
@@ -97,10 +101,27 @@ public sealed class MatchStore(BrowserBridge browser)
public async Task ClearAsync()
{
var storageScope = await SyncStorageScopeAsync();
Current = null;
IsLoaded = true;
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
await browser.ClearMatchAsync();
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
}
private async ValueTask<UserStorageScope> SyncStorageScopeAsync()
{
var storageScope = await userSession.GetStorageScopeAsync();
if (_activeStorageKey is not null &&
(_activeStorageKey != storageScope.StorageKey || _activeWindowNameKey != storageScope.WindowNameKey))
{
Current = null;
IsLoaded = false;
_dirty = false;
}
_activeStorageKey = storageScope.StorageKey;
_activeWindowNameKey = storageScope.WindowNameKey;
return storageScope;
}
}

View File

@@ -0,0 +1,39 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
namespace ChessCubing.App.Services;
public sealed class UserSession(AuthenticationStateProvider authenticationStateProvider)
{
public async ValueTask<UserStorageScope> GetStorageScopeAsync()
{
var authState = await authenticationStateProvider.GetAuthenticationStateAsync();
var userId = ResolveUserId(authState.User);
return new UserStorageScope(
$"{MatchStore.StorageKeyPrefix}:{userId}",
$"{MatchStore.WindowNameKeyPrefix}:{userId}:");
}
private static string ResolveUserId(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return "anonymous";
}
var rawIdentifier = user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name;
if (string.IsNullOrWhiteSpace(rawIdentifier))
{
return "authenticated";
}
return Uri.EscapeDataString(rawIdentifier.Trim());
}
}
public readonly record struct UserStorageScope(string StorageKey, string WindowNameKey);

View File

@@ -4,8 +4,11 @@
@using ChessCubing.App.Components
@using ChessCubing.App.Models
@using ChessCubing.App.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.JSInterop

View File

@@ -0,0 +1,13 @@
{
"Keycloak": {
"Authority": "/auth/realms/chesscubing",
"ClientId": "chesscubing-web",
"ResponseType": "code",
"PostLogoutRedirectUri": "/",
"DefaultScopes": [
"openid",
"profile",
"email"
]
}
}

View File

@@ -146,14 +146,16 @@
}
},
clearMatchState(storageKey) {
clearMatchState(storageKey, windowNameKey) {
try {
window.localStorage.removeItem(storageKey);
} catch {
}
try {
window.name = "";
if (window.name && window.name.startsWith(windowNameKey)) {
window.name = "";
}
} catch {
}
},