Ajoute une page utilisateur et une persistance MySQL
This commit is contained in:
@@ -8,4 +8,8 @@
|
||||
<AssemblyName>ChessCubing.Server</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ChessCubing.Server.Data;
|
||||
|
||||
public sealed class SiteDataOptions
|
||||
{
|
||||
public string Host { get; set; } = "mysql";
|
||||
|
||||
public int Port { get; set; } = 3306;
|
||||
|
||||
public string Database { get; set; } = "chesscubing_site";
|
||||
|
||||
public string Username { get; set; } = "chesscubing";
|
||||
|
||||
public string Password { get; set; } = "chesscubing";
|
||||
|
||||
public int InitializationRetries { get; set; } = 12;
|
||||
|
||||
public int InitializationDelaySeconds { get; set; } = 3;
|
||||
|
||||
public string BuildConnectionString()
|
||||
=> new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = Host,
|
||||
Port = checked((uint)Port),
|
||||
Database = Database,
|
||||
UserID = Username,
|
||||
Password = Password,
|
||||
CharacterSet = "utf8mb4",
|
||||
Pooling = true,
|
||||
ConnectionTimeout = 15,
|
||||
}.ConnectionString;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using ChessCubing.Server.Auth;
|
||||
using ChessCubing.Server.Data;
|
||||
using ChessCubing.Server.Users;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
@@ -17,6 +19,20 @@ builder.Services.AddOptions<KeycloakAuthOptions>()
|
||||
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<SiteDataOptions>()
|
||||
.Configure<IConfiguration>((options, configuration) =>
|
||||
{
|
||||
options.Host = configuration["SITE_DB_HOST"] ?? options.Host;
|
||||
options.Database = configuration["SITE_DB_NAME"] ?? options.Database;
|
||||
options.Username = configuration["SITE_DB_USER"] ?? options.Username;
|
||||
options.Password = configuration["SITE_DB_PASSWORD"] ?? options.Password;
|
||||
|
||||
if (int.TryParse(configuration["SITE_DB_PORT"], out var port) && port > 0)
|
||||
{
|
||||
options.Port = port;
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
@@ -44,9 +60,16 @@ builder.Services
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
||||
await profileStore.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -55,6 +78,44 @@ app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
|
||||
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
|
||||
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
|
||||
|
||||
app.MapGet("/api/users/me", async Task<IResult> (
|
||||
ClaimsPrincipal user,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||
if (siteUser is null)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||
}
|
||||
|
||||
var profile = await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
|
||||
return TypedResults.Ok(profile);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPut("/api/users/me", async Task<IResult> (
|
||||
UpdateUserProfileRequest request,
|
||||
ClaimsPrincipal user,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||
if (siteUser is null)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await profileStore.UpdateAsync(siteUser, request, cancellationToken);
|
||||
return TypedResults.Ok(profile);
|
||||
}
|
||||
catch (UserProfileValidationException exception)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||
}
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/auth/login", async Task<IResult> (
|
||||
LoginRequest request,
|
||||
HttpContext httpContext,
|
||||
|
||||
40
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
40
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ChessCubing.Server.Users;
|
||||
|
||||
public sealed record AuthenticatedSiteUser(
|
||||
string Subject,
|
||||
string Username,
|
||||
string? Email,
|
||||
string DisplayName);
|
||||
|
||||
public static class AuthenticatedSiteUserFactory
|
||||
{
|
||||
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subject = user.FindFirst("sub")?.Value
|
||||
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = user.FindFirst("preferred_username")?.Value
|
||||
?? user.Identity?.Name
|
||||
?? subject;
|
||||
var email = user.FindFirst("email")?.Value;
|
||||
var displayName = user.FindFirst("name")?.Value
|
||||
?? username;
|
||||
|
||||
return new AuthenticatedSiteUser(
|
||||
subject.Trim(),
|
||||
username.Trim(),
|
||||
string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
displayName.Trim());
|
||||
}
|
||||
}
|
||||
296
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
296
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using ChessCubing.Server.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ChessCubing.Server.Users;
|
||||
|
||||
public sealed class MySqlUserProfileStore(
|
||||
IOptions<SiteDataOptions> options,
|
||||
ILogger<MySqlUserProfileStore> logger)
|
||||
{
|
||||
private const string CreateTableSql = """
|
||||
CREATE TABLE IF NOT EXISTS site_users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
subject VARCHAR(190) NOT NULL,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(255) NULL,
|
||||
display_name VARCHAR(120) NOT NULL,
|
||||
club VARCHAR(120) NULL,
|
||||
city VARCHAR(120) NULL,
|
||||
preferred_format VARCHAR(40) NULL,
|
||||
favorite_cube VARCHAR(120) NULL,
|
||||
bio TEXT NULL,
|
||||
created_utc DATETIME(6) NOT NULL,
|
||||
updated_utc DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_site_users_subject (subject)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
""";
|
||||
|
||||
private const string UpsertIdentitySql = """
|
||||
INSERT INTO site_users (
|
||||
subject,
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
created_utc,
|
||||
updated_utc
|
||||
)
|
||||
VALUES (
|
||||
@subject,
|
||||
@username,
|
||||
@email,
|
||||
@displayName,
|
||||
@createdUtc,
|
||||
@updatedUtc
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
username = VALUES(username),
|
||||
email = VALUES(email);
|
||||
""";
|
||||
|
||||
private const string UpsertProfileSql = """
|
||||
INSERT INTO site_users (
|
||||
subject,
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
club,
|
||||
city,
|
||||
preferred_format,
|
||||
favorite_cube,
|
||||
bio,
|
||||
created_utc,
|
||||
updated_utc
|
||||
)
|
||||
VALUES (
|
||||
@subject,
|
||||
@username,
|
||||
@email,
|
||||
@displayName,
|
||||
@club,
|
||||
@city,
|
||||
@preferredFormat,
|
||||
@favoriteCube,
|
||||
@bio,
|
||||
@createdUtc,
|
||||
@updatedUtc
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
username = VALUES(username),
|
||||
email = VALUES(email),
|
||||
display_name = VALUES(display_name),
|
||||
club = VALUES(club),
|
||||
city = VALUES(city),
|
||||
preferred_format = VALUES(preferred_format),
|
||||
favorite_cube = VALUES(favorite_cube),
|
||||
bio = VALUES(bio),
|
||||
updated_utc = VALUES(updated_utc);
|
||||
""";
|
||||
|
||||
private const string SelectProfileSql = """
|
||||
SELECT
|
||||
subject,
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
club,
|
||||
city,
|
||||
preferred_format,
|
||||
favorite_cube,
|
||||
bio,
|
||||
created_utc,
|
||||
updated_utc
|
||||
FROM site_users
|
||||
WHERE subject = @subject
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
private readonly SiteDataOptions _options = options.Value;
|
||||
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = CreateTableSql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Initialisation MySQL impossible pour le profil utilisateur (tentative {Attempt}/{MaxAttempts}).",
|
||||
attempt,
|
||||
_options.InitializationRetries);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await finalConnection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var finalCommand = finalConnection.CreateCommand();
|
||||
finalCommand.CommandText = CreateTableSql;
|
||||
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse> GetOrCreateAsync(
|
||||
AuthenticatedSiteUser user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = UpsertIdentitySql;
|
||||
command.Parameters.AddWithValue("@subject", user.Subject);
|
||||
command.Parameters.AddWithValue("@username", user.Username);
|
||||
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@displayName", user.DisplayName);
|
||||
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse> UpdateAsync(
|
||||
AuthenticatedSiteUser user,
|
||||
UpdateUserProfileRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var input = NormalizeInput(user, request);
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = UpsertProfileSql;
|
||||
command.Parameters.AddWithValue("@subject", user.Subject);
|
||||
command.Parameters.AddWithValue("@username", user.Username);
|
||||
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
||||
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||
}
|
||||
|
||||
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||
{
|
||||
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? user.DisplayName;
|
||||
var club = NormalizeOptionalValue(request.Club, "club", 120);
|
||||
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
||||
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
|
||||
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
|
||||
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
|
||||
|
||||
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
throw new UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizePreferredFormat(string? value)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
|
||||
if (normalized is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"Twice" => "Twice",
|
||||
"Time" => "Time",
|
||||
"Les deux" => "Les deux",
|
||||
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<UserProfileResponse> ReadProfileAsync(
|
||||
MySqlConnection connection,
|
||||
string subject,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = SelectProfileSql;
|
||||
command.Parameters.AddWithValue("@subject", subject);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
|
||||
}
|
||||
|
||||
var subjectOrdinal = reader.GetOrdinal("subject");
|
||||
var usernameOrdinal = reader.GetOrdinal("username");
|
||||
var emailOrdinal = reader.GetOrdinal("email");
|
||||
var displayNameOrdinal = reader.GetOrdinal("display_name");
|
||||
var clubOrdinal = reader.GetOrdinal("club");
|
||||
var cityOrdinal = reader.GetOrdinal("city");
|
||||
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
|
||||
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
|
||||
var bioOrdinal = reader.GetOrdinal("bio");
|
||||
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
|
||||
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
|
||||
|
||||
return new UserProfileResponse
|
||||
{
|
||||
Subject = reader.GetString(subjectOrdinal),
|
||||
Username = reader.GetString(usernameOrdinal),
|
||||
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
|
||||
DisplayName = reader.GetString(displayNameOrdinal),
|
||||
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
|
||||
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
|
||||
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
|
||||
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
|
||||
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
|
||||
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
|
||||
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record UserProfileInput(
|
||||
string DisplayName,
|
||||
string? Club,
|
||||
string? City,
|
||||
string? PreferredFormat,
|
||||
string? FavoriteCube,
|
||||
string? Bio);
|
||||
}
|
||||
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace ChessCubing.Server.Users;
|
||||
|
||||
public sealed class UserProfileResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
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 DateTime CreatedUtc { get; init; }
|
||||
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserProfileRequest
|
||||
{
|
||||
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 class UserProfileValidationException(string message) : Exception(message);
|
||||
Reference in New Issue
Block a user