Ajoute une zone d'administration des utilisateurs

This commit is contained in:
2026-04-15 21:21:26 +02:00
parent 106786a638
commit 1d18a070e5
12 changed files with 1595 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.Server.Admin;
using Microsoft.Extensions.Options;
namespace ChessCubing.Server.Auth;
@@ -14,6 +15,11 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
{
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
private readonly HttpClient _httpClient = httpClient;
private readonly KeycloakAuthOptions _options = options.Value;
@@ -32,6 +38,87 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var users = new List<AdminIdentityUser>();
const int pageSize = 100;
for (var first = 0; ; first += pageSize)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
}
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
users.AddRange(page
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
.Select(MapAdminIdentityUser));
if (page.Count < pageSize)
{
break;
}
}
return users;
}
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
string userId,
AdminIdentityUserUpdateRequest request,
CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
{
Content = JsonContent.Create(new
{
username = request.Username,
email = request.Email,
enabled = request.IsEnabled,
emailVerified = request.IsEmailVerified,
firstName = request.FirstName,
lastName = request.LastName,
}, options: UpdateJsonOptions)
})
{
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.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
}
}
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
@@ -235,6 +322,44 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return users?.FirstOrDefault()?.Id;
}
private async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
}
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
{
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
}
return MapAdminIdentityUser(user);
}
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
=> new(
user.Id!,
user.Username!,
user.Email,
user.FirstName,
user.LastName,
user.Enabled ?? true,
user.EmailVerified ?? false,
user.CreatedTimestamp is > 0
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
: null);
private string[] ExtractRealmRoles(string accessToken)
{
try
@@ -326,6 +451,33 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
[JsonPropertyName("description")]
public string? Description { get; init; }
}
private sealed class AdminUserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("username")]
public string? Username { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("firstName")]
public string? FirstName { get; init; }
[JsonPropertyName("lastName")]
public string? LastName { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("emailVerified")]
public bool? EmailVerified { get; init; }
[JsonPropertyName("createdTimestamp")]
public long? CreatedTimestamp { get; init; }
}
}
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)