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;
}
}