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

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