Integrer l'authentification Keycloak dans l'application

This commit is contained in:
2026-04-13 23:59:20 +02:00
parent 53f0af761e
commit 9b739b02f6
20 changed files with 1201 additions and 276 deletions

View File

@@ -1,8 +1,12 @@
@using System.ComponentModel.DataAnnotations
@using System.Net.Http.Json
@using System.Security.Claims
@implements IAsyncDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
@using ChessCubing.App.Models.Auth
@using Microsoft.AspNetCore.Components.Authorization
@implements IDisposable
@inject AppAuthenticationStateProvider AuthenticationStateProvider
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IJSRuntime JS
<div class="site-menu-shell">
<header class="site-menu-bar">
@@ -22,7 +26,7 @@
</nav>
<div class="site-menu-account">
<span class="micro-label">Compte Keycloak</span>
<span class="micro-label">Compte joueur</span>
@if (IsAuthenticated)
{
<div class="site-menu-account-panel">
@@ -30,7 +34,7 @@
<strong>@DisplayName</strong>
<span>@DisplayMeta</span>
</div>
<a class="button ghost small" href="@LogoutHref">Se deconnecter</a>
<button class="button ghost small" type="button" @onclick="LogoutAsync" disabled="@IsSubmitting">Se deconnecter</button>
</div>
}
else
@@ -50,19 +54,97 @@
<div class="modal-card auth-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Compte Keycloak</p>
<p class="eyebrow">Compte joueur</p>
<h2>@AuthModalTitle</h2>
</div>
<button class="button ghost small" type="button" @onclick="CloseAuthModal">Fermer</button>
<button class="button ghost small" type="button" @onclick="CloseAuthModal" disabled="@IsSubmitting">Fermer</button>
</div>
<p class="auth-modal-copy">
L'authentification se fait maintenant dans cette fenetre integree, sans quitter la page en cours.
L'authentification se fait maintenant directement dans l'application, sans redirection vers une page externe.
</p>
@if (!string.IsNullOrWhiteSpace(AuthModalSource))
<div class="auth-modal-switch">
<button class="@BuildModeButtonClass(AuthMode.Login)" type="button" @onclick="SwitchToLogin" disabled="@IsSubmitting">Se connecter</button>
<button class="@BuildModeButtonClass(AuthMode.Register)" type="button" @onclick="SwitchToRegister" disabled="@IsSubmitting">Creer un compte</button>
</div>
@if (!string.IsNullOrWhiteSpace(FormError))
{
<div class="auth-modal-frame-shell">
<iframe class="auth-modal-frame" src="@AuthModalSource" title="@AuthModalTitle"></iframe>
</div>
<p class="auth-form-error">@FormError</p>
}
@if (Mode == AuthMode.Login)
{
<EditForm Model="@LoginModel" OnValidSubmit="SubmitLoginAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid">
<label class="field">
<span>Identifiant ou email</span>
<InputText @bind-Value="LoginModel.Username" autocomplete="username email" />
<ValidationMessage For="@(() => LoginModel.Username)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="LoginModel.Password" type="password" autocomplete="current-password" />
<ValidationMessage For="@(() => LoginModel.Password)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Connexion..." : "Se connecter")
</button>
</div>
</EditForm>
}
else
{
<EditForm Model="@RegisterModel" OnValidSubmit="SubmitRegisterAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid two-columns">
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="RegisterModel.FirstName" autocomplete="given-name" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="RegisterModel.LastName" autocomplete="family-name" />
</label>
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="RegisterModel.Username" autocomplete="username" />
<ValidationMessage For="@(() => RegisterModel.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="RegisterModel.Email" autocomplete="email" />
<ValidationMessage For="@(() => RegisterModel.Email)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="RegisterModel.Password" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.Password)" />
</label>
<label class="field">
<span>Confirmation du mot de passe</span>
<InputText @bind-Value="RegisterModel.ConfirmPassword" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Creation..." : "Creer mon compte")
</button>
</div>
</EditForm>
}
</div>
</section>
@@ -72,22 +154,18 @@
private static readonly string[] ApplicationPaths = ["application", "application.html"];
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
private DotNetObjectReference<SiteMenu>? _dotNetReference;
private bool _listenerRegistered;
private bool _authStateSubscribed;
private string? AuthModalSource;
private string AuthModalTitle = "Authentification";
private bool ShowAuthModal;
private readonly LoginFormModel LoginModel = new();
private readonly RegisterFormModel RegisterModel = new();
private bool IsAuthenticated;
private bool ShowAuthModal;
private bool IsSubmitting;
private string? FormError;
private string AuthModalTitle = "Se connecter";
private AuthMode Mode = AuthMode.Login;
private string DisplayName = "Utilisateur connecte";
private string DisplayMeta = "Session active";
private string LoginHref => BuildAuthHref("login", EffectiveReturnUrl);
private string RegisterHref => BuildAuthHref("register", EffectiveReturnUrl);
private string LoginModalSrc => BuildAuthHref("login", EffectiveReturnUrl, embedded: true);
private string RegisterModalSrc => BuildAuthHref("register", EffectiveReturnUrl, embedded: true);
private string LogoutHref => BuildAuthHref("logout", "/");
private string CurrentPath
{
get
@@ -97,20 +175,10 @@
}
}
private string EffectiveReturnUrl
protected override async Task OnInitializedAsync()
{
get
{
var absolutePath = new Uri(Navigation.Uri).AbsolutePath;
if (absolutePath.StartsWith("/authentication/", StringComparison.OrdinalIgnoreCase))
{
return "/";
}
return string.IsNullOrWhiteSpace(absolutePath)
? "/"
: absolutePath;
}
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await RefreshAuthenticationStateAsync();
}
private string BuildNavLinkClass(string[] paths)
@@ -122,88 +190,138 @@
private bool IsCurrentPage(string[] paths)
=> paths.Any(path => string.Equals(CurrentPath, path, StringComparison.OrdinalIgnoreCase));
private static string BuildAuthHref(string action, string returnUrl, bool embedded = false)
private void OpenLoginModal()
{
var query = $"returnUrl={Uri.EscapeDataString(returnUrl)}";
if (embedded)
{
query += "&embedded=1";
}
return $"authentication/{action}?{query}";
}
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)
=> user.FindFirst("email")?.Value
?? "Session active";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
_authStateSubscribed = true;
await RefreshAuthenticationStateAsync();
}
private async Task OpenLoginModal()
=> await OpenAuthModalAsync("Se connecter", LoginModalSrc);
private async Task OpenRegisterModal()
=> await OpenAuthModalAsync("Creer un compte", RegisterModalSrc);
private async Task OpenAuthModalAsync(string title, string source)
{
await EnsureAuthListenerAsync();
AuthModalTitle = title;
AuthModalSource = source;
ShowAuthModal = true;
SwitchToLogin();
}
private void OpenRegisterModal()
{
ShowAuthModal = true;
SwitchToRegister();
}
private void CloseAuthModal()
{
ShowAuthModal = false;
AuthModalSource = null;
}
private async Task EnsureAuthListenerAsync()
{
if (_listenerRegistered)
if (IsSubmitting)
{
return;
}
_dotNetReference ??= DotNetObjectReference.Create(this);
ShowAuthModal = false;
FormError = null;
}
private void SwitchToLogin()
{
Mode = AuthMode.Login;
AuthModalTitle = "Se connecter";
FormError = null;
}
private void SwitchToRegister()
{
Mode = AuthMode.Register;
AuthModalTitle = "Creer un compte";
FormError = null;
}
private string BuildModeButtonClass(AuthMode mode)
=> mode == Mode ? "button secondary small" : "button ghost small";
private async Task SubmitLoginAsync()
{
await SubmitAsync(
"api/auth/login",
new LoginRequest
{
Username = LoginModel.Username.Trim(),
Password = LoginModel.Password,
},
"Connexion impossible.");
}
private async Task SubmitRegisterAsync()
{
await SubmitAsync(
"api/auth/register",
new RegisterRequest
{
Username = RegisterModel.Username.Trim(),
Email = RegisterModel.Email.Trim(),
Password = RegisterModel.Password,
ConfirmPassword = RegisterModel.ConfirmPassword,
FirstName = RegisterModel.FirstName?.Trim(),
LastName = RegisterModel.LastName?.Trim(),
},
"Creation du compte impossible.");
}
private async Task SubmitAsync<TRequest>(string endpoint, TRequest payload, string fallbackMessage)
{
if (IsSubmitting)
{
return;
}
IsSubmitting = true;
FormError = null;
try
{
await JS.InvokeVoidAsync("chesscubingAuthModal.registerListener", _dotNetReference);
_listenerRegistered = true;
var response = await Http.PostAsJsonAsync(endpoint, payload);
if (!response.IsSuccessStatusCode)
{
FormError = await ReadErrorAsync(response, fallbackMessage);
return;
}
var session = await response.Content.ReadFromJsonAsync<AuthSessionResponse>();
if (session is not null && session.IsAuthenticated)
{
AuthenticationStateProvider.SetAuthenticated(session);
}
else
{
await AuthenticationStateProvider.RefreshAsync();
}
ShowAuthModal = false;
FormError = null;
ResetForms();
await RefreshAuthenticationStateAsync();
}
catch
{
// Si le navigateur garde encore un ancien script en cache,
// on laisse l'application fonctionner et la modal reste utilisable sans auto-fermeture.
FormError = fallbackMessage;
}
finally
{
IsSubmitting = false;
}
}
[JSInvokable]
public Task HandleAuthModalMessage(string status)
private async Task LogoutAsync()
{
if (!string.Equals(status, "login-succeeded", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(status, "logout-succeeded", StringComparison.OrdinalIgnoreCase))
if (IsSubmitting)
{
return Task.CompletedTask;
return;
}
CloseAuthModal();
StateHasChanged();
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
return Task.CompletedTask;
IsSubmitting = true;
FormError = null;
try
{
await Http.PostAsync("api/auth/logout", null);
AuthenticationStateProvider.SetAnonymous();
await RefreshAuthenticationStateAsync();
}
finally
{
IsSubmitting = false;
}
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
@@ -231,6 +349,8 @@
{
ResetAuthenticationDisplay();
}
StateHasChanged();
}
private void ResetAuthenticationDisplay()
@@ -240,27 +360,90 @@
DisplayMeta = "Session active";
}
public async ValueTask DisposeAsync()
private void ResetForms()
{
if (_authStateSubscribed)
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
}
LoginModel.Username = string.Empty;
LoginModel.Password = string.Empty;
RegisterModel.FirstName = string.Empty;
RegisterModel.LastName = string.Empty;
RegisterModel.Username = string.Empty;
RegisterModel.Email = string.Empty;
RegisterModel.Password = string.Empty;
RegisterModel.ConfirmPassword = string.Empty;
}
if (_listenerRegistered)
private static string BuildDisplayName(ClaimsPrincipal user)
=> user.FindFirst("name")?.Value
?? user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name
?? "Utilisateur connecte";
private static string BuildMeta(ClaimsPrincipal user)
=> user.FindFirst("email")?.Value
?? "Session active";
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
try
{
try
{
await JS.InvokeVoidAsync("chesscubingAuthModal.unregisterListener");
}
catch
var error = await response.Content.ReadFromJsonAsync<ApiErrorMessage>();
if (!string.IsNullOrWhiteSpace(error?.Message))
{
return error.Message;
}
}
catch
{
}
_dotNetReference?.Dispose();
return fallbackMessage;
}
private static string BoolString(bool value)
=> value ? "true" : "false";
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
private enum AuthMode
{
Login,
Register,
}
private sealed class ApiErrorMessage
{
public string? Message { get; set; }
}
private sealed class LoginFormModel
{
[Required(ErrorMessage = "L'identifiant est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
public string Password { get; set; } = string.Empty;
}
private sealed class RegisterFormModel
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "L'email est obligatoire.")]
[EmailAddress(ErrorMessage = "L'email n'est pas valide.")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
[MinLength(8, ErrorMessage = "Le mot de passe doit contenir au moins 8 caracteres.")]
public string Password { get; set; } = string.Empty;
[Required(ErrorMessage = "La confirmation est obligatoire.")]
[Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}

View File

@@ -25,8 +25,7 @@ else
|| string.Equals(currentPath, "cube", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "cube.html", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase)
|| currentPath.StartsWith("authentication/", StringComparison.OrdinalIgnoreCase);
|| string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,16 @@
namespace ChessCubing.App.Models.Auth;
public sealed class AuthSessionResponse
{
public bool IsAuthenticated { get; set; }
public string? Subject { get; set; }
public string? Username { get; set; }
public string? Name { get; set; }
public string? Email { get; set; }
public string[] Roles { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace ChessCubing.App.Models.Auth;
public sealed class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
namespace ChessCubing.App.Models.Auth;
public sealed class RegisterRequest
{
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
}

View File

@@ -1,109 +1,16 @@
@page "/authentication/{action}"
@inject NavigationManager Navigation
@inject IJSRuntime JS
<main class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<p class="eyebrow">Authentification</p>
@if (StartsInteractiveFlow)
{
<div>
<strong>@(IsRegisterAction ? "Ouverture de la creation de compte..." : "Ouverture de la connexion...")</strong>
<p>
@(IsRegisterAction
? "Le formulaire Keycloak s'ouvre dans cette fenetre integree."
: "Le formulaire de connexion Keycloak s'ouvre dans cette fenetre integree.")
</p>
</div>
}
else
{
<RemoteAuthenticatorView Action="@Action" />
}
<div>
<strong>Le systeme d'authentification est maintenant integre a l'application.</strong>
<p>Utilise les boutons du menu en haut de page pour te connecter ou creer un compte sans quitter le site.</p>
</div>
</section>
</main>
@code {
[Parameter]
public string? Action { get; set; }
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
[SupplyParameterFromQuery(Name = "embedded")]
public string? Embedded { get; set; }
private bool IsRegisterAction
=> string.Equals(Action, RemoteAuthenticationActions.Register, StringComparison.OrdinalIgnoreCase);
private bool IsLoginAction
=> string.Equals(Action, RemoteAuthenticationActions.LogIn, StringComparison.OrdinalIgnoreCase);
private bool IsEmbeddedFlow
=> string.Equals(Embedded, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Embedded, "true", StringComparison.OrdinalIgnoreCase);
private bool StartsInteractiveFlow
=> IsEmbeddedFlow && (IsLoginAction || IsRegisterAction);
protected override void OnParametersSet()
{
if (!StartsInteractiveFlow)
{
return;
}
var request = new InteractiveRequestOptions
{
Interaction = InteractionType.SignIn,
ReturnUrl = NormalizeReturnUrl(ReturnUrl)
};
if (IsRegisterAction)
{
request.TryAddAdditionalParameter("prompt", "create");
}
Navigation.NavigateToLogin("authentication/login", request);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
var status = Action switch
{
RemoteAuthenticationActions.LogInCallback => "login-succeeded",
RemoteAuthenticationActions.LogOutCallback => "logout-succeeded",
_ => null,
};
if (status is null || !IsEmbeddedFlow)
{
return;
}
try
{
await JS.InvokeVoidAsync("chesscubingAuthModal.notifyParent", status);
}
catch
{
}
}
private static string NormalizeReturnUrl(string? returnUrl)
{
if (string.IsNullOrWhiteSpace(returnUrl))
{
return "/";
}
return returnUrl.StartsWith("/", StringComparison.Ordinal)
? returnUrl
: $"/{returnUrl}";
}
}

View File

@@ -2,57 +2,18 @@ using ChessCubing.App;
using ChessCubing.App.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
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.AddAuthorizationCore();
builder.Services.AddScoped<AppAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AppAuthenticationStateProvider>());
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

@@ -0,0 +1,101 @@
using System.Net.Http.Json;
using System.Security.Claims;
using ChessCubing.App.Models.Auth;
using Microsoft.AspNetCore.Components.Authorization;
namespace ChessCubing.App.Services;
public sealed class AppAuthenticationStateProvider(HttpClient httpClient) : AuthenticationStateProvider
{
private static readonly AuthenticationState AnonymousState = new(new ClaimsPrincipal(new ClaimsIdentity()));
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_cachedState is not null)
{
return _cachedState;
}
_cachedState = await LoadStateAsync();
return _cachedState;
}
public async Task RefreshAsync()
{
_cachedState = await LoadStateAsync();
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
public void SetAuthenticated(AuthSessionResponse session)
{
_cachedState = new AuthenticationState(BuildPrincipal(session));
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
public void SetAnonymous()
{
_cachedState = AnonymousState;
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
}
private async Task<AuthenticationState> LoadStateAsync()
{
try
{
var session = await httpClient.GetFromJsonAsync<AuthSessionResponse>("api/auth/session");
if (session is null || !session.IsAuthenticated)
{
return AnonymousState;
}
return new AuthenticationState(BuildPrincipal(session));
}
catch
{
return AnonymousState;
}
}
private static ClaimsPrincipal BuildPrincipal(AuthSessionResponse session)
{
if (!session.IsAuthenticated)
{
return AnonymousState.User;
}
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(session.Subject))
{
claims.Add(new Claim("sub", session.Subject));
claims.Add(new Claim(ClaimTypes.NameIdentifier, session.Subject));
}
if (!string.IsNullOrWhiteSpace(session.Username))
{
claims.Add(new Claim("preferred_username", session.Username));
claims.Add(new Claim(ClaimTypes.Name, session.Username));
}
if (!string.IsNullOrWhiteSpace(session.Name))
{
claims.Add(new Claim("name", session.Name));
}
if (!string.IsNullOrWhiteSpace(session.Email))
{
claims.Add(new Claim("email", session.Email));
claims.Add(new Claim(ClaimTypes.Email, session.Email));
}
foreach (var role in session.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
{
claims.Add(new Claim("role", role));
claims.Add(new Claim(ClaimTypes.Role, role));
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, "ChessCubingCookie"));
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
namespace ChessCubing.Server.Auth;
public sealed record LoginRequest(string Username, string Password);
public sealed record RegisterRequest(
string Username,
string Email,
string Password,
string ConfirmPassword,
string? FirstName,
string? LastName);
public sealed record ApiErrorResponse(string Message);
public sealed class AuthSessionResponse
{
public bool IsAuthenticated { get; init; }
public string? Subject { get; init; }
public string? Username { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public string[] Roles { get; init; } = [];
public static AuthSessionResponse FromUser(ClaimsPrincipal user)
=> new()
{
IsAuthenticated = user.Identity?.IsAuthenticated == true,
Subject = user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Username = user.FindFirst("preferred_username")?.Value ?? user.Identity?.Name,
Name = user.FindFirst("name")?.Value ?? user.Identity?.Name,
Email = user.FindFirst("email")?.Value,
Roles = user.FindAll("role").Select(claim => claim.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
};
}
public sealed class KeycloakUserInfo
{
[JsonPropertyName("sub")]
public string? Subject { get; init; }
[JsonPropertyName("preferred_username")]
public string? PreferredUsername { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
public string[] Roles { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthOptions
{
public string BaseUrl { get; set; } = "http://keycloak:8080/auth";
public string Realm { get; set; } = "chesscubing";
public string ClientId { get; set; } = "chesscubing-web";
public string AdminRealm { get; set; } = "master";
public string AdminClientId { get; set; } = "admin-cli";
public string AdminUsername { get; set; } = "admin";
public string AdminPassword { get; set; } = "admin";
}

View File

@@ -0,0 +1,334 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<KeycloakAuthOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _httpClient = httpClient;
private readonly KeycloakAuthOptions _options = options.Value;
public async Task<KeycloakUserInfo> LoginAsync(string username, string password, CancellationToken cancellationToken)
{
var tokenResponse = await RequestPasswordTokenAsync(username, password, cancellationToken);
return await GetUserInfoAsync(tokenResponse.AccessToken!, cancellationToken);
}
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(adminToken, request, cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.ClientId,
["grant_type"] = "password",
["scope"] = "openid profile email",
["username"] = username,
["password"] = password,
};
using var response = await _httpClient.PostAsync(
$"{GetRealmBaseUrl()}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await ReadJsonAsync<TokenErrorResponse>(response, cancellationToken);
var message = error?.ErrorDescription switch
{
not null when error.ErrorDescription.Contains("Account is not fully set up", StringComparison.OrdinalIgnoreCase)
=> "Le compte existe mais n'est pas encore actif dans Keycloak.",
not null => "Identifiants invalides ou connexion Keycloak indisponible.",
_ => "Connexion Keycloak impossible.",
};
throw new KeycloakAuthException(message, (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (token?.AccessToken is null)
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton utilisable.");
}
return token;
}
private async Task<string> RequestAdminTokenAsync(CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.AdminClientId,
["grant_type"] = "password",
["username"] = _options.AdminUsername,
["password"] = _options.AdminPassword,
};
using var response = await _httpClient.PostAsync(
$"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.AdminRealm)}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible d'obtenir un acces admin Keycloak.", (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (string.IsNullOrWhiteSpace(token?.AccessToken))
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton admin.");
}
return token.AccessToken;
}
private async Task<KeycloakUserInfo> GetUserInfoAsync(string accessToken, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetRealmBaseUrl()}/protocol/openid-connect/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer le profil utilisateur Keycloak.", (int)response.StatusCode);
}
var userInfo = await ReadJsonAsync<KeycloakUserInfo>(response, cancellationToken);
if (userInfo is null)
{
throw new KeycloakAuthException("Le profil Keycloak est invalide.");
}
userInfo.Roles = ExtractRealmRoles(accessToken);
return userInfo;
}
private async Task<string> CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
{
Content = JsonContent.Create(new
{
username = request.Username.Trim(),
email = request.Email.Trim(),
enabled = true,
emailVerified = false,
firstName = string.IsNullOrWhiteSpace(request.FirstName) ? null : request.FirstName.Trim(),
lastName = string.IsNullOrWhiteSpace(request.LastName) ? null : request.LastName.Trim(),
}, options: JsonOptions)
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Creation du compte impossible dans Keycloak.", (int)response.StatusCode);
}
var userId = response.Headers.Location?.Segments.LastOrDefault();
if (!string.IsNullOrWhiteSpace(userId))
{
return userId.Trim('/');
}
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken);
if (!string.IsNullOrWhiteSpace(fallbackUserId))
{
return fallbackUserId;
}
throw new KeycloakAuthException("Le compte a ete cree mais l'identifiant Keycloak est introuvable.");
}
private async Task SetPasswordAsync(string adminToken, string userId, string password, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/reset-password")
{
Content = JsonContent.Create(new
{
type = "password",
value = password,
temporary = false,
})
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Le compte a ete cree mais le mot de passe n'a pas pu etre defini.", (int)response.StatusCode);
}
}
private async Task TryAssignPlayerRoleAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var roleRequest = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/roles/player");
roleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var roleResponse = await _httpClient.SendAsync(roleRequest, cancellationToken);
if (!roleResponse.IsSuccessStatusCode)
{
return;
}
var role = await ReadJsonAsync<RealmRoleRepresentation>(roleResponse, cancellationToken);
if (role?.Name is null)
{
return;
}
using var assignRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/role-mappings/realm")
{
Content = JsonContent.Create(new[]
{
new RealmRoleRepresentation
{
Id = role.Id,
Name = role.Name,
Description = role.Description,
}
})
};
assignRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var assignResponse = await _httpClient.SendAsync(assignRequest, cancellationToken);
_ = assignResponse.IsSuccessStatusCode;
}
private async Task<string?> FindUserIdByUsernameAsync(string adminToken, string username, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?username={Uri.EscapeDataString(username)}&exact=true");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var users = await ReadJsonAsync<List<UserRepresentation>>(response, cancellationToken);
return users?.FirstOrDefault()?.Id;
}
private string[] ExtractRealmRoles(string accessToken)
{
try
{
var tokenParts = accessToken.Split('.');
if (tokenParts.Length < 2)
{
return [];
}
var payloadBytes = DecodeBase64Url(tokenParts[1]);
using var document = JsonDocument.Parse(payloadBytes);
if (!document.RootElement.TryGetProperty("realm_access", out var realmAccess) ||
!realmAccess.TryGetProperty("roles", out var rolesElement) ||
rolesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return rolesElement
.EnumerateArray()
.Select(role => role.GetString())
.Where(role => !string.IsNullOrWhiteSpace(role))
.Cast<string>()
.ToArray();
}
catch
{
return [];
}
}
private static byte[] DecodeBase64Url(string input)
{
var normalized = input.Replace('-', '+').Replace('_', '/');
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
return Convert.FromBase64String(normalized);
}
private string GetBaseUrl()
=> _options.BaseUrl.TrimEnd('/');
private string GetRealmBaseUrl()
=> $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.Realm)}";
private string GetAdminBaseUrl()
=> $"{GetBaseUrl()}/admin/realms/{Uri.EscapeDataString(_options.Realm)}";
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(content))
{
return default;
}
return JsonSerializer.Deserialize<T>(content, JsonOptions);
}
private sealed class TokenSuccessResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
}
private sealed class TokenErrorResponse
{
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; init; }
}
private sealed class UserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
}
private sealed class RealmRoleRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
}
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ChessCubing.Server</RootNamespace>
<AssemblyName>ChessCubing.Server</AssemblyName>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,173 @@
using System.Security.Claims;
using ChessCubing.Server.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<KeycloakAuthOptions>()
.Configure<IConfiguration>((options, configuration) =>
{
options.BaseUrl = configuration["KEYCLOAK_BASE_URL"] ?? options.BaseUrl;
options.Realm = configuration["KEYCLOAK_REALM"] ?? options.Realm;
options.ClientId = configuration["KEYCLOAK_CLIENT_ID"] ?? options.ClientId;
options.AdminRealm = configuration["KEYCLOAK_ADMIN_REALM"] ?? options.AdminRealm;
options.AdminClientId = configuration["KEYCLOAK_ADMIN_CLIENT_ID"] ?? options.AdminClientId;
options.AdminUsername = configuration["KEYCLOAK_ADMIN_USERNAME"] ?? options.AdminUsername;
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
});
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "chesscubing.auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
builder.Services.AddHttpClient<KeycloakAuthService>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
app.MapPost("/api/auth/login", async Task<IResult> (
LoginRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur et mot de passe obligatoires."));
}
try
{
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
await SignInAsync(httpContext, userInfo);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
app.MapPost("/api/auth/register", async Task<IResult> (
RegisterRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Email) ||
string.IsNullOrWhiteSpace(request.Password))
{
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur, email et mot de passe obligatoires."));
}
if (!string.Equals(request.Password, request.ConfirmPassword, StringComparison.Ordinal))
{
return TypedResults.BadRequest(new ApiErrorResponse("Les mots de passe ne correspondent pas."));
}
try
{
var userInfo = await keycloak.RegisterAsync(request with
{
Username = request.Username.Trim(),
Email = request.Email.Trim(),
FirstName = request.FirstName?.Trim(),
LastName = request.LastName?.Trim(),
}, cancellationToken);
await SignInAsync(httpContext, userInfo);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return TypedResults.Ok(AuthSessionResponse.FromUser(new ClaimsPrincipal(new ClaimsIdentity())));
});
app.Run();
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
{
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(userInfo.Subject))
{
claims.Add(new Claim("sub", userInfo.Subject));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userInfo.Subject));
}
if (!string.IsNullOrWhiteSpace(userInfo.PreferredUsername))
{
claims.Add(new Claim("preferred_username", userInfo.PreferredUsername));
claims.Add(new Claim(ClaimTypes.Name, userInfo.PreferredUsername));
}
if (!string.IsNullOrWhiteSpace(userInfo.Name))
{
claims.Add(new Claim("name", userInfo.Name));
}
if (!string.IsNullOrWhiteSpace(userInfo.Email))
{
claims.Add(new Claim("email", userInfo.Email));
claims.Add(new Claim(ClaimTypes.Email, userInfo.Email));
}
foreach (var role in userInfo.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
{
claims.Add(new Claim("role", role));
claims.Add(new Claim(ClaimTypes.Role, role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
AllowRefresh = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7),
});
httpContext.User = principal;
}

17
Dockerfile.auth Normal file
View File

@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ChessCubing.Server/ChessCubing.Server.csproj ChessCubing.Server/
RUN dotnet restore ChessCubing.Server/ChessCubing.Server.csproj
COPY ChessCubing.Server/ ChessCubing.Server/
RUN dotnet publish ChessCubing.Server/ChessCubing.Server.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=build /app/publish ./
ENTRYPOINT ["dotnet", "ChessCubing.Server.dll"]

View File

@@ -22,29 +22,34 @@ 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/Services/AppAuthenticationStateProvider.cs` : session locale cote client
- `ChessCubing.Server/` : backend d'authentification qui parle a Keycloak
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
- `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
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
- `keycloak/scripts/init-config.sh` : synchronisation automatique du client Keycloak au demarrage
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : front Blazor, API d'auth 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.
L'application embarque maintenant une authentification integree basee sur Keycloak, sans redirection utilisateur vers une page externe.
- toutes les pages du site restent accessibles sans connexion
- un menu general en haut des pages site regroupe la navigation et les actions `Se connecter` / `Creer un compte` dans une modal integree
- l'action `Creer un compte` ouvre le parcours d'inscription natif de Keycloak
- les roles Keycloak du realm sont exposes dans l'application
- le formulaire de connexion et le formulaire de creation de compte sont rendus directement dans l'application
- un backend local `ChessCubing.Server` appelle Keycloak cote serveur pour la connexion, l'inscription et la recuperation du profil
- une session cookie locale est ensuite exposee au front via `/api/auth/session`
- les roles Keycloak du realm restent 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`
- client Keycloak : `chesscubing-web`
- roles de realm : `admin`, `organizer`, `player`
- inscription utilisateur : activee
- direct access grant : active
La gestion des utilisateurs se fait ensuite dans la console d'administration Keycloak.
@@ -60,6 +65,7 @@ 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/`.
L'API d'authentification integree est servie derriere le meme point d'entree via `/api/auth/*`.
Identifiants d'administration par defaut pour le premier demarrage local :
@@ -68,6 +74,8 @@ Identifiants d'administration par defaut pour le premier demarrage local :
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
Au demarrage, le service `keycloak-init` resynchronise automatiquement le realm courant pour garder l'inscription active et autoriser le flux de connexion integre, meme si la base Keycloak existe deja.
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
@@ -77,7 +85,7 @@ 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/`.
Le mode Docker reste la voie recommandee pour cette integration, car nginx y reverse-proxy egalement Keycloak sous `/auth/` et l'API d'auth sous `/api/`.
## Deploiement dans un LXC Proxmox
@@ -131,9 +139,10 @@ 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
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*`
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
- `docker-compose.yml` + `Dockerfile` : execution locale
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : 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

@@ -1,14 +1,37 @@
services:
web:
build: .
build:
context: .
dockerfile: Dockerfile
container_name: chesscubing-web
depends_on:
auth:
condition: service_started
keycloak:
condition: service_started
ports:
- "8080:80"
restart: unless-stopped
auth:
build:
context: .
dockerfile: Dockerfile.auth
container_name: chesscubing-auth
environment:
ASPNETCORE_URLS: http://+:8080
KEYCLOAK_BASE_URL: http://keycloak:8080/auth
KEYCLOAK_REALM: chesscubing
KEYCLOAK_CLIENT_ID: chesscubing-web
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
depends_on:
keycloak-init:
condition: service_completed_successfully
keycloak:
condition: service_started
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.1.2
container_name: chesscubing-keycloak
@@ -33,6 +56,23 @@ services:
condition: service_healthy
restart: unless-stopped
keycloak-init:
image: quay.io/keycloak/keycloak:26.1.2
container_name: chesscubing-keycloak-init
entrypoint: ["/bin/sh", "/opt/keycloak/data/scripts/init-config.sh"]
environment:
KEYCLOAK_URL: http://keycloak:8080/auth
KEYCLOAK_REALM: chesscubing
KEYCLOAK_CLIENT_ID: chesscubing-web
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
volumes:
- ./keycloak/scripts:/opt/keycloak/data/scripts:ro
depends_on:
keycloak:
condition: service_started
restart: "no"
postgres:
image: postgres:16-alpine
container_name: chesscubing-keycloak-db

View File

@@ -34,7 +34,7 @@
"protocol": "openid-connect",
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"implicitFlowEnabled": false,
"frontchannelLogout": true,

View File

@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080/auth}"
KEYCLOAK_REALM="${KEYCLOAK_REALM:-chesscubing}"
KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-chesscubing-web}"
KEYCLOAK_ADMIN_USERNAME="${KEYCLOAK_ADMIN_USERNAME:-admin}"
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
echo "Attente de Keycloak..."
until /opt/keycloak/bin/kcadm.sh config credentials \
--server "$KEYCLOAK_URL" \
--realm master \
--user "$KEYCLOAK_ADMIN_USERNAME" \
--password "$KEYCLOAK_ADMIN_PASSWORD" >/dev/null 2>&1; do
sleep 2
done
CLIENT_INTERNAL_ID="$(
/opt/keycloak/bin/kcadm.sh get clients \
-r "$KEYCLOAK_REALM" \
-q clientId="$KEYCLOAK_CLIENT_ID" \
--fields id \
--format csv \
--noquotes | tail -n 1
)"
if [ -z "$CLIENT_INTERNAL_ID" ]; then
echo "Client Keycloak introuvable: $KEYCLOAK_CLIENT_ID"
exit 1
fi
/opt/keycloak/bin/kcadm.sh update "clients/$CLIENT_INTERNAL_ID" \
-r "$KEYCLOAK_REALM" \
-s directAccessGrantsEnabled=true \
-s standardFlowEnabled=true \
-s publicClient=true >/dev/null
/opt/keycloak/bin/kcadm.sh update "realms/$KEYCLOAK_REALM" \
-s registrationAllowed=true \
-s loginWithEmailAllowed=true >/dev/null
echo "Configuration Keycloak synchronisee."

View File

@@ -31,6 +31,16 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ {
proxy_http_version 1.1;
proxy_pass http://auth:8080/api/;
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

@@ -1171,19 +1171,39 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
color: var(--muted);
}
.auth-modal-frame-shell {
overflow: hidden;
border-radius: 22px;
border: 1px solid var(--panel-border);
background: rgba(10, 12, 17, 0.85);
.auth-modal-switch {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.auth-modal-frame {
display: block;
width: 100%;
height: min(78dvh, 760px);
border: 0;
background: #141414;
.auth-form-grid {
display: grid;
gap: 0.85rem;
}
.auth-form-grid.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auth-form-error {
margin: 0 0 1rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(255, 100, 127, 0.28);
border-radius: 18px;
color: #ffd8de;
background: rgba(255, 100, 127, 0.12);
}
.validation-message {
color: #ffb3c0;
font-size: 0.88rem;
line-height: 1.35;
}
.auth-modal-card .field {
margin: 0;
}
.hero-rules h1 {
@@ -1534,8 +1554,8 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
padding: 1rem;
}
.auth-modal-frame {
height: min(70dvh, 680px);
.auth-form-grid.two-columns {
grid-template-columns: 1fr;
}
.setup-form {