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

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
KEYCLOAK_DB_NAME=keycloak
KEYCLOAK_DB_USER=keycloak
KEYCLOAK_DB_PASSWORD=change-me
KEYCLOAK_ADMIN_USER=admin
KEYCLOAK_ADMIN_PASSWORD=change-me

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.codex
.env
WhatsApp Video 2026-04-11 at 20.38.50.mp4
ChessCubing.App/bin/
ChessCubing.App/obj/

View File

@@ -1,5 +1,20 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<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 {
if (window.name && window.name.startsWith(windowNameKey)) {
window.name = "";
}
} catch {
}
},

View File

@@ -22,11 +22,30 @@ Le coeur de l'application se trouve dans `ChessCubing.App/`.
- `ChessCubing.App/Pages/` : pages Razor du site et de l'application
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
- `ChessCubing.App/Services/KeycloakAccountFactory.cs` : adaptation des roles Keycloak vers les claims Blazor
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
- `docker-compose.yml` + `Dockerfile` : build Blazor puis service via nginx
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client OIDC et roles
- `docker-compose.yml` + `Dockerfile` : build Blazor, service via nginx et stack Keycloak/Postgres
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
## Authentification Keycloak
L'application embarque maintenant une authentification OpenID Connect basee sur Keycloak.
- les pages `application.html`, `chrono.html` et `cube.html` demandent une connexion
- la page d'accueil et la page reglement affichent l'etat de session courant
- les roles Keycloak du realm sont exposes dans l'application
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
Le realm importe par defaut :
- realm : `chesscubing`
- client public OIDC : `chesscubing-web`
- roles de realm : `admin`, `organizer`, `player`
La gestion des utilisateurs se fait ensuite dans la console d'administration Keycloak.
## Demarrage local
### Avec Docker
@@ -38,6 +57,17 @@ docker compose up -d --build
L'application est ensuite disponible sur `http://localhost:8080`.
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
Identifiants d'administration par defaut pour le premier demarrage local :
- utilisateur : `admin`
- mot de passe : `admin`
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
Si vous voulez reimporter completement le realm fourni dans `keycloak/realm/`, il faut repartir d'une base vide, par exemple avec `docker compose down -v` avant le redemarrage.
### Avec .NET 10
```bash
@@ -45,6 +75,8 @@ dotnet restore ChessCubing.App/ChessCubing.App.csproj
dotnet run --project ChessCubing.App/ChessCubing.App.csproj
```
Le mode Docker reste la voie recommandee pour cette integration, car nginx y reverse-proxy egalement Keycloak sous `/auth/`.
## Deploiement dans un LXC Proxmox
Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git.
@@ -56,6 +88,8 @@ Prerrequis sur la machine qui lance les scripts :
Le deploiement dans le LXC n'utilise pas Docker. Le script clone le depot, publie l'application Blazor dans le conteneur, puis sert le resultat via `nginx`.
Attention : la pile Keycloak fournie ici est actuellement prete a l'emploi dans la stack Docker du projet. Les scripts LXC existants ne provisionnent pas encore automatiquement Keycloak ni sa base Postgres.
### Installer un nouveau LXC
```bash
@@ -95,6 +129,9 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
- `ChessCubing.App/Services/KeycloakAccountFactory.cs` : transformation des roles Keycloak en claims Blazor
- `ChessCubing.App/wwwroot/appsettings.json` : configuration OIDC du client WebAssembly
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
- `docker-compose.yml` + `Dockerfile` : execution locale
- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox
- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git

View File

@@ -2,6 +2,52 @@ services:
web:
build: .
container_name: chesscubing-web
depends_on:
keycloak:
condition: service_started
ports:
- "8080:80"
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.1.2
container_name: chesscubing-keycloak
command: ["start-dev", "--import-realm"]
environment:
KC_DB: postgres
KC_DB_URL_HOST: postgres
KC_DB_URL_PORT: 5432
KC_DB_URL_DATABASE: ${KEYCLOAK_DB_NAME:-keycloak}
KC_DB_USERNAME: ${KEYCLOAK_DB_USER:-keycloak}
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_PROXY_HEADERS: xforwarded
KC_HOSTNAME: http://localhost:8080/auth
KC_HTTP_RELATIVE_PATH: /auth
KC_HOSTNAME_STRICT: "false"
volumes:
- ./keycloak/realm:/opt/keycloak/data/import:ro
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: chesscubing-keycloak-db
environment:
POSTGRES_DB: ${KEYCLOAK_DB_NAME:-keycloak}
POSTGRES_USER: ${KEYCLOAK_DB_USER:-keycloak}
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
volumes:
- keycloak-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${KEYCLOAK_DB_USER:-keycloak} -d ${KEYCLOAK_DB_NAME:-keycloak}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
keycloak-postgres:

View File

@@ -0,0 +1,62 @@
{
"realm": "chesscubing",
"enabled": true,
"displayName": "ChessCubing",
"registrationAllowed": false,
"rememberMe": true,
"resetPasswordAllowed": true,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"editUsernameAllowed": false,
"sslRequired": "external",
"roles": {
"realm": [
{
"name": "admin",
"description": "Administrateur ChessCubing"
},
{
"name": "organizer",
"description": "Organisateur de rencontre"
},
{
"name": "player",
"description": "Joueur ChessCubing"
}
]
},
"clients": [
{
"clientId": "chesscubing-web",
"name": "ChessCubing Web",
"description": "Client OIDC public pour l'application Blazor WebAssembly.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"implicitFlowEnabled": false,
"frontchannelLogout": true,
"rootUrl": "http://localhost:8080/",
"baseUrl": "http://localhost:8080/",
"redirectUris": [
"http://localhost:8080/*"
],
"webOrigins": [
"http://localhost:8080"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8080/*"
},
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
]
}
]
}

View File

@@ -21,6 +21,16 @@ server {
try_files $uri $uri/ /brice/index.html;
}
location /auth/ {
proxy_http_version 1.1;
proxy_pass http://keycloak:8080/auth/;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -1047,6 +1047,36 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
margin-top: 1.2rem;
}
.user-access-bar {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
padding: 1rem;
border-radius: 24px;
border: 1px solid var(--panel-border);
background: rgba(12, 14, 20, 0.76);
}
.user-access-copy {
display: grid;
gap: 0.35rem;
}
.user-access-copy strong {
font-size: 1.02rem;
}
.user-access-meta {
color: var(--muted);
line-height: 1.45;
}
.user-access-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.rule-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1310,12 +1340,23 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
text-align: center;
}
.user-access-bar {
grid-template-columns: 1fr;
}
.brand-link,
.utility-button {
justify-self: center;
}
}
@media (min-width: 720px) {
.user-access-bar {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
}
@media (max-width: 900px) {
.phase-body {
overflow-y: auto;