Files
chesscubing/ChessCubing.Server/Program.cs

235 lines
8.2 KiB
C#

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;
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();
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();
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.Run();
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;
}