Files
chesscubing/ChessCubing.Server/Program.cs

591 lines
20 KiB
C#

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<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.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 =>
{
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<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();
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();
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly");
adminGroup.MapGet("/users", async Task<IResult> (
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<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,
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<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,
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<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.MapGet("/api/auth/logout/browser", async Task<IResult> (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<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;
}
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);