471 lines
16 KiB
C#
471 lines
16 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.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.MapPut("/users/{subject}", async Task<IResult> (
|
|
string subject,
|
|
AdminUpdateUserRequest request,
|
|
KeycloakAuthService keycloak,
|
|
MySqlUserProfileStore profileStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
try
|
|
{
|
|
var normalized = NormalizeAdminUpdate(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 updatedIdentity = await keycloak.UpdateAdminUserAsync(
|
|
subject,
|
|
new AdminIdentityUserUpdateRequest(
|
|
normalized.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 username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
|
|
var email = NormalizeEmail(request.Email);
|
|
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
|
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
|
|
|
return new NormalizedAdminUserUpdate(
|
|
username,
|
|
email,
|
|
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? 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 Username,
|
|
string? Email,
|
|
string? FirstName,
|
|
string? LastName,
|
|
bool IsEnabled,
|
|
bool IsEmailVerified);
|