Integrer l'authentification Keycloak dans l'application
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
ChessCubing.App/Models/Auth/AuthSessionResponse.cs
Normal file
16
ChessCubing.App/Models/Auth/AuthSessionResponse.cs
Normal 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; } = [];
|
||||
}
|
||||
8
ChessCubing.App/Models/Auth/LoginRequest.cs
Normal file
8
ChessCubing.App/Models/Auth/LoginRequest.cs
Normal 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;
|
||||
}
|
||||
16
ChessCubing.App/Models/Auth/RegisterRequest.cs
Normal file
16
ChessCubing.App/Models/Auth/RegisterRequest.cs
Normal 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; }
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
101
ChessCubing.App/Services/AppAuthenticationStateProvider.cs
Normal file
101
ChessCubing.App/Services/AppAuthenticationStateProvider.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
59
ChessCubing.Server/Auth/AuthContracts.cs
Normal file
59
ChessCubing.Server/Auth/AuthContracts.cs
Normal 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; } = [];
|
||||
}
|
||||
18
ChessCubing.Server/Auth/KeycloakAuthOptions.cs
Normal file
18
ChessCubing.Server/Auth/KeycloakAuthOptions.cs
Normal 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";
|
||||
}
|
||||
334
ChessCubing.Server/Auth/KeycloakAuthService.cs
Normal file
334
ChessCubing.Server/Auth/KeycloakAuthService.cs
Normal 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;
|
||||
}
|
||||
11
ChessCubing.Server/ChessCubing.Server.csproj
Normal file
11
ChessCubing.Server/ChessCubing.Server.csproj
Normal 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>
|
||||
173
ChessCubing.Server/Program.cs
Normal file
173
ChessCubing.Server/Program.cs
Normal 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
17
Dockerfile.auth
Normal 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"]
|
||||
31
README.md
31
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"frontchannelLogout": true,
|
||||
|
||||
43
keycloak/scripts/init-config.sh
Normal file
43
keycloak/scripts/init-config.sh
Normal 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."
|
||||
10
nginx.conf
10
nginx.conf
@@ -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;
|
||||
}
|
||||
|
||||
46
styles.css
46
styles.css
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user