using System.Security.Claims; using System.Net.Mail; using ChessCubing.Server.Admin; using ChessCubing.Server.Auth; using ChessCubing.Server.Data; using ChessCubing.Server.Users; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOptions() .Configure((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.AddOptions() .Configure((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 => { 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(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin")); }); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); var app = builder.Build(); await using (var scope = app.Services.CreateAsyncScope()) { var profileStore = scope.ServiceProvider.GetRequiredService(); await profileStore.InitializeAsync(CancellationToken.None); } 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.MapGet("/api/users/me", async Task ( 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 ( 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(); var adminGroup = app.MapGroup("/api/admin") .RequireAuthorization("AdminOnly"); adminGroup.MapGet("/users", async Task ( KeycloakAuthService keycloak, MySqlUserProfileStore profileStore, CancellationToken cancellationToken) => { var identityUsersTask = keycloak.GetAdminUsersAsync(cancellationToken); var siteProfilesTask = profileStore.ListAsync(cancellationToken); await Task.WhenAll(identityUsersTask, siteProfilesTask); var siteProfilesBySubject = (await siteProfilesTask) .ToDictionary(profile => profile.Subject, StringComparer.Ordinal); var users = (await identityUsersTask) .Select(identity => MapAdminSummary(identity, siteProfilesBySubject.GetValueOrDefault(identity.Subject))) .OrderByDescending(user => user.SiteProfileUpdatedUtc ?? user.AccountCreatedUtc ?? DateTime.MinValue) .ThenBy(user => user.Username, StringComparer.OrdinalIgnoreCase) .ToArray(); return TypedResults.Ok(users); }); adminGroup.MapPost("/users", async Task ( 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 ( string subject, KeycloakAuthService keycloak, MySqlUserProfileStore profileStore, CancellationToken cancellationToken) => { try { var identityTask = keycloak.GetAdminUserAsync(subject, cancellationToken); var profileTask = profileStore.FindBySubjectAsync(subject, cancellationToken); await Task.WhenAll(identityTask, profileTask); return TypedResults.Ok(MapAdminDetail(await identityTask, await profileTask)); } catch (KeycloakAuthException exception) { return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode); } }); adminGroup.MapDelete("/users/{subject}", async Task ( 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 ( string subject, AdminUpdateUserRequest request, KeycloakAuthService keycloak, MySqlUserProfileStore profileStore, CancellationToken cancellationToken) => { try { var existingIdentity = await keycloak.GetAdminUserAsync(subject, cancellationToken); var normalized = NormalizeAdminUpdate(request); var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, existingIdentity.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 updatedIdentity = await keycloak.UpdateAdminUserAsync( subject, new AdminIdentityUserUpdateRequest( existingIdentity.Username, normalized.Email, normalized.FirstName, normalized.LastName, normalized.IsEnabled, normalized.IsEmailVerified), cancellationToken); var updatedProfile = await profileStore.AdminUpsertAsync( updatedIdentity.Subject, updatedIdentity.Username, updatedIdentity.Email, BuildIdentityDisplayName(updatedIdentity), siteProfileRequest, cancellationToken); return TypedResults.Ok(MapAdminDetail(updatedIdentity, updatedProfile)); } 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); } }); app.MapPost("/api/auth/login", async Task ( 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 ( 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 (HttpContext httpContext) => { await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return TypedResults.Ok(AuthSessionResponse.FromUser(new ClaimsPrincipal(new ClaimsIdentity()))); }); app.MapGet("/api/auth/logout/browser", async Task (HttpContext httpContext) => { await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return TypedResults.Redirect("/index.html"); }); app.Run(); static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile) => new() { Subject = identity.Subject, Username = identity.Username, Email = identity.Email, IdentityDisplayName = BuildIdentityDisplayName(identity), SiteDisplayName = profile?.DisplayName, IsEnabled = identity.IsEnabled, IsEmailVerified = identity.IsEmailVerified, HasSiteProfile = profile is not null, Club = profile?.Club, City = profile?.City, PreferredFormat = profile?.PreferredFormat, AccountCreatedUtc = identity.CreatedUtc, SiteProfileUpdatedUtc = profile?.UpdatedUtc, }; static AdminUserDetailResponse MapAdminDetail(AdminIdentityUser identity, UserProfileResponse? profile) { var identityDisplayName = BuildIdentityDisplayName(identity); return new AdminUserDetailResponse { Subject = identity.Subject, Username = identity.Username, Email = identity.Email, FirstName = identity.FirstName, LastName = identity.LastName, IdentityDisplayName = identityDisplayName, IsEnabled = identity.IsEnabled, IsEmailVerified = identity.IsEmailVerified, AccountCreatedUtc = identity.CreatedUtc, HasSiteProfile = profile is not null, DisplayName = profile?.DisplayName ?? identityDisplayName, Club = profile?.Club, City = profile?.City, PreferredFormat = profile?.PreferredFormat, FavoriteCube = profile?.FavoriteCube, Bio = profile?.Bio, SiteProfileCreatedUtc = profile?.CreatedUtc, SiteProfileUpdatedUtc = profile?.UpdatedUtc, }; } static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest request) { var email = NormalizeEmail(request.Email); var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120); var lastName = NormalizeOptionalValue(request.LastName, "nom", 120); return new NormalizedAdminUserUpdate( email, firstName, lastName, request.IsEnabled, 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); static string BuildIdentityDisplayNameFromParts(string? firstName, string? lastName, string username) { var fullName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value))); return string.IsNullOrWhiteSpace(fullName) ? username : fullName; } static string NormalizeRequiredValue(string? value, string fieldName, int maxLength) { var normalized = NormalizeOptionalValue(value, fieldName, maxLength); return normalized ?? throw new AdminUserValidationException($"Le champ {fieldName} est obligatoire."); } static string? NormalizeEmail(string? value) { var normalized = NormalizeOptionalValue(value, "email", 255); if (normalized is null) { return null; } try { _ = new MailAddress(normalized); return normalized; } catch (FormatException) { throw new AdminUserValidationException("L'email n'est pas valide."); } } 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(); if (string.IsNullOrWhiteSpace(trimmed)) { return null; } if (trimmed.Length > maxLength) { throw new AdminUserValidationException($"Le champ {fieldName} depasse {maxLength} caracteres."); } return trimmed; } static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo) { var claims = new List(); 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; } sealed record NormalizedAdminUserUpdate( string? Email, string? FirstName, string? LastName, bool IsEnabled, bool IsEmailVerified); sealed record NormalizedAdminCreateUser( string Username, string? Email, string Password, string? FirstName, string? LastName, bool IsEnabled, bool IsEmailVerified);