Ajoute une table de gestion des utilisateurs
This commit is contained in:
@@ -95,6 +95,37 @@ public sealed class AdminUpdateUserRequest
|
||||
public string? Bio { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AdminCreateUserRequest
|
||||
{
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string ConfirmPassword { get; init; } = string.Empty;
|
||||
|
||||
public string? FirstName { get; init; }
|
||||
|
||||
public string? LastName { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
public bool IsEmailVerified { get; init; }
|
||||
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
public string? Club { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public string? PreferredFormat { get; init; }
|
||||
|
||||
public string? FavoriteCube { get; init; }
|
||||
|
||||
public string? Bio { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdminIdentityUser(
|
||||
string Subject,
|
||||
string Username,
|
||||
@@ -113,4 +144,13 @@ public sealed record AdminIdentityUserUpdateRequest(
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
public sealed record AdminIdentityUserCreateRequest(
|
||||
string Username,
|
||||
string? Email,
|
||||
string Password,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
public sealed class AdminUserValidationException(string message) : Exception(message);
|
||||
|
||||
@@ -32,12 +32,41 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
var userId = await CreateUserAsync(adminToken, request, cancellationToken);
|
||||
var userId = await CreateUserAsync(
|
||||
adminToken,
|
||||
request.Username,
|
||||
request.Email,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
isEnabled: true,
|
||||
isEmailVerified: false,
|
||||
cancellationToken);
|
||||
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AdminIdentityUser> CreateAdminUserAsync(
|
||||
AdminIdentityUserCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
var userId = await CreateUserAsync(
|
||||
adminToken,
|
||||
request.Username,
|
||||
request.Email,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.IsEnabled,
|
||||
request.IsEmailVerified,
|
||||
cancellationToken);
|
||||
|
||||
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||
|
||||
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
@@ -119,6 +148,25 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{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("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var formData = new Dictionary<string, string>
|
||||
@@ -208,18 +256,26 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private async Task<string> CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken)
|
||||
private async Task<string> CreateUserAsync(
|
||||
string adminToken,
|
||||
string username,
|
||||
string? email,
|
||||
string? firstName,
|
||||
string? lastName,
|
||||
bool isEnabled,
|
||||
bool isEmailVerified,
|
||||
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(),
|
||||
username = username.Trim(),
|
||||
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
enabled = isEnabled,
|
||||
emailVerified = isEmailVerified,
|
||||
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
|
||||
lastName = string.IsNullOrWhiteSpace(lastName) ? null : lastName.Trim(),
|
||||
}, options: JsonOptions)
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||
@@ -241,7 +297,7 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
return userId.Trim('/');
|
||||
}
|
||||
|
||||
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken);
|
||||
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, username, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fallbackUserId))
|
||||
{
|
||||
return fallbackUserId;
|
||||
|
||||
@@ -147,6 +147,63 @@ adminGroup.MapGet("/users", async Task<IResult> (
|
||||
return TypedResults.Ok(users);
|
||||
});
|
||||
|
||||
adminGroup.MapPost("/users", async Task<IResult> (
|
||||
AdminCreateUserRequest request,
|
||||
KeycloakAuthService keycloak,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = NormalizeAdminCreate(request);
|
||||
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, normalized.Username);
|
||||
var siteProfileRequest = new UpdateUserProfileRequest
|
||||
{
|
||||
DisplayName = request.DisplayName,
|
||||
Club = request.Club,
|
||||
City = request.City,
|
||||
PreferredFormat = request.PreferredFormat,
|
||||
FavoriteCube = request.FavoriteCube,
|
||||
Bio = request.Bio,
|
||||
};
|
||||
|
||||
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
|
||||
|
||||
var createdIdentity = await keycloak.CreateAdminUserAsync(
|
||||
new AdminIdentityUserCreateRequest(
|
||||
normalized.Username,
|
||||
normalized.Email,
|
||||
normalized.Password,
|
||||
normalized.FirstName,
|
||||
normalized.LastName,
|
||||
normalized.IsEnabled,
|
||||
normalized.IsEmailVerified),
|
||||
cancellationToken);
|
||||
|
||||
var createdProfile = await profileStore.AdminUpsertAsync(
|
||||
createdIdentity.Subject,
|
||||
createdIdentity.Username,
|
||||
createdIdentity.Email,
|
||||
BuildIdentityDisplayName(createdIdentity),
|
||||
siteProfileRequest,
|
||||
cancellationToken);
|
||||
|
||||
return TypedResults.Created($"/api/admin/users/{Uri.EscapeDataString(createdIdentity.Subject)}", MapAdminDetail(createdIdentity, createdProfile));
|
||||
}
|
||||
catch (AdminUserValidationException exception)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||
}
|
||||
catch (UserProfileValidationException exception)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||
}
|
||||
catch (KeycloakAuthException exception)
|
||||
{
|
||||
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||
}
|
||||
});
|
||||
|
||||
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
||||
string subject,
|
||||
KeycloakAuthService keycloak,
|
||||
@@ -167,6 +224,24 @@ adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
||||
}
|
||||
});
|
||||
|
||||
adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
|
||||
string subject,
|
||||
KeycloakAuthService keycloak,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
|
||||
await profileStore.DeleteAsync(subject, cancellationToken);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (KeycloakAuthException exception)
|
||||
{
|
||||
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||
}
|
||||
});
|
||||
|
||||
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
|
||||
string subject,
|
||||
AdminUpdateUserRequest request,
|
||||
@@ -360,6 +435,24 @@ static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest req
|
||||
request.IsEmailVerified);
|
||||
}
|
||||
|
||||
static NormalizedAdminCreateUser NormalizeAdminCreate(AdminCreateUserRequest request)
|
||||
{
|
||||
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var password = NormalizePassword(request.Password, request.ConfirmPassword);
|
||||
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
||||
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
||||
|
||||
return new NormalizedAdminCreateUser(
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
request.IsEnabled,
|
||||
request.IsEmailVerified);
|
||||
}
|
||||
|
||||
static string BuildIdentityDisplayName(AdminIdentityUser identity)
|
||||
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
|
||||
|
||||
@@ -396,6 +489,26 @@ static string? NormalizeEmail(string? value)
|
||||
}
|
||||
}
|
||||
|
||||
static string NormalizePassword(string? password, string? confirmPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
throw new AdminUserValidationException("Le mot de passe est obligatoire.");
|
||||
}
|
||||
|
||||
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
|
||||
{
|
||||
throw new AdminUserValidationException("Les mots de passe ne correspondent pas.");
|
||||
}
|
||||
|
||||
if (password.Length < 8)
|
||||
{
|
||||
throw new AdminUserValidationException("Le mot de passe doit contenir au moins 8 caracteres.");
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
@@ -468,3 +581,12 @@ sealed record NormalizedAdminUserUpdate(
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
sealed record NormalizedAdminCreateUser(
|
||||
string Username,
|
||||
string? Email,
|
||||
string Password,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
@@ -123,6 +123,11 @@ public sealed class MySqlUserProfileStore(
|
||||
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
||||
""";
|
||||
|
||||
private const string DeleteProfileSql = """
|
||||
DELETE FROM site_users
|
||||
WHERE subject = @subject;
|
||||
""";
|
||||
|
||||
private readonly SiteDataOptions _options = options.Value;
|
||||
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||
|
||||
@@ -285,6 +290,17 @@ public sealed class MySqlUserProfileStore(
|
||||
return await ReadProfileAsync(connection, subject, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = DeleteProfileSql;
|
||||
command.Parameters.AddWithValue("@subject", subject);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||
=> NormalizeInput(user.DisplayName, request);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user