Integrer l'authentification Keycloak dans l'application

This commit is contained in:
2026-04-13 23:59:20 +02:00
parent 53f0af761e
commit 9b739b02f6
20 changed files with 1201 additions and 276 deletions

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
namespace ChessCubing.Server.Auth;
public sealed record LoginRequest(string Username, string Password);
public sealed record RegisterRequest(
string Username,
string Email,
string Password,
string ConfirmPassword,
string? FirstName,
string? LastName);
public sealed record ApiErrorResponse(string Message);
public sealed class AuthSessionResponse
{
public bool IsAuthenticated { get; init; }
public string? Subject { get; init; }
public string? Username { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public string[] Roles { get; init; } = [];
public static AuthSessionResponse FromUser(ClaimsPrincipal user)
=> new()
{
IsAuthenticated = user.Identity?.IsAuthenticated == true,
Subject = user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Username = user.FindFirst("preferred_username")?.Value ?? user.Identity?.Name,
Name = user.FindFirst("name")?.Value ?? user.Identity?.Name,
Email = user.FindFirst("email")?.Value,
Roles = user.FindAll("role").Select(claim => claim.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
};
}
public sealed class KeycloakUserInfo
{
[JsonPropertyName("sub")]
public string? Subject { get; init; }
[JsonPropertyName("preferred_username")]
public string? PreferredUsername { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
public string[] Roles { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthOptions
{
public string BaseUrl { get; set; } = "http://keycloak:8080/auth";
public string Realm { get; set; } = "chesscubing";
public string ClientId { get; set; } = "chesscubing-web";
public string AdminRealm { get; set; } = "master";
public string AdminClientId { get; set; } = "admin-cli";
public string AdminUsername { get; set; } = "admin";
public string AdminPassword { get; set; } = "admin";
}

View File

@@ -0,0 +1,334 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace ChessCubing.Server.Auth;
public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<KeycloakAuthOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _httpClient = httpClient;
private readonly KeycloakAuthOptions _options = options.Value;
public async Task<KeycloakUserInfo> LoginAsync(string username, string password, CancellationToken cancellationToken)
{
var tokenResponse = await RequestPasswordTokenAsync(username, password, cancellationToken);
return await GetUserInfoAsync(tokenResponse.AccessToken!, cancellationToken);
}
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(adminToken, request, cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.ClientId,
["grant_type"] = "password",
["scope"] = "openid profile email",
["username"] = username,
["password"] = password,
};
using var response = await _httpClient.PostAsync(
$"{GetRealmBaseUrl()}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await ReadJsonAsync<TokenErrorResponse>(response, cancellationToken);
var message = error?.ErrorDescription switch
{
not null when error.ErrorDescription.Contains("Account is not fully set up", StringComparison.OrdinalIgnoreCase)
=> "Le compte existe mais n'est pas encore actif dans Keycloak.",
not null => "Identifiants invalides ou connexion Keycloak indisponible.",
_ => "Connexion Keycloak impossible.",
};
throw new KeycloakAuthException(message, (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (token?.AccessToken is null)
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton utilisable.");
}
return token;
}
private async Task<string> RequestAdminTokenAsync(CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
{
["client_id"] = _options.AdminClientId,
["grant_type"] = "password",
["username"] = _options.AdminUsername,
["password"] = _options.AdminPassword,
};
using var response = await _httpClient.PostAsync(
$"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.AdminRealm)}/protocol/openid-connect/token",
new FormUrlEncodedContent(formData),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible d'obtenir un acces admin Keycloak.", (int)response.StatusCode);
}
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
if (string.IsNullOrWhiteSpace(token?.AccessToken))
{
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton admin.");
}
return token.AccessToken;
}
private async Task<KeycloakUserInfo> GetUserInfoAsync(string accessToken, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetRealmBaseUrl()}/protocol/openid-connect/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer le profil utilisateur Keycloak.", (int)response.StatusCode);
}
var userInfo = await ReadJsonAsync<KeycloakUserInfo>(response, cancellationToken);
if (userInfo is null)
{
throw new KeycloakAuthException("Le profil Keycloak est invalide.");
}
userInfo.Roles = ExtractRealmRoles(accessToken);
return userInfo;
}
private async Task<string> CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
{
Content = JsonContent.Create(new
{
username = request.Username.Trim(),
email = request.Email.Trim(),
enabled = true,
emailVerified = false,
firstName = string.IsNullOrWhiteSpace(request.FirstName) ? null : request.FirstName.Trim(),
lastName = string.IsNullOrWhiteSpace(request.LastName) ? null : request.LastName.Trim(),
}, options: JsonOptions)
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Creation du compte impossible dans Keycloak.", (int)response.StatusCode);
}
var userId = response.Headers.Location?.Segments.LastOrDefault();
if (!string.IsNullOrWhiteSpace(userId))
{
return userId.Trim('/');
}
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken);
if (!string.IsNullOrWhiteSpace(fallbackUserId))
{
return fallbackUserId;
}
throw new KeycloakAuthException("Le compte a ete cree mais l'identifiant Keycloak est introuvable.");
}
private async Task SetPasswordAsync(string adminToken, string userId, string password, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/reset-password")
{
Content = JsonContent.Create(new
{
type = "password",
value = password,
temporary = false,
})
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Le compte a ete cree mais le mot de passe n'a pas pu etre defini.", (int)response.StatusCode);
}
}
private async Task TryAssignPlayerRoleAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var roleRequest = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/roles/player");
roleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var roleResponse = await _httpClient.SendAsync(roleRequest, cancellationToken);
if (!roleResponse.IsSuccessStatusCode)
{
return;
}
var role = await ReadJsonAsync<RealmRoleRepresentation>(roleResponse, cancellationToken);
if (role?.Name is null)
{
return;
}
using var assignRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/role-mappings/realm")
{
Content = JsonContent.Create(new[]
{
new RealmRoleRepresentation
{
Id = role.Id,
Name = role.Name,
Description = role.Description,
}
})
};
assignRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var assignResponse = await _httpClient.SendAsync(assignRequest, cancellationToken);
_ = assignResponse.IsSuccessStatusCode;
}
private async Task<string?> FindUserIdByUsernameAsync(string adminToken, string username, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?username={Uri.EscapeDataString(username)}&exact=true");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var users = await ReadJsonAsync<List<UserRepresentation>>(response, cancellationToken);
return users?.FirstOrDefault()?.Id;
}
private string[] ExtractRealmRoles(string accessToken)
{
try
{
var tokenParts = accessToken.Split('.');
if (tokenParts.Length < 2)
{
return [];
}
var payloadBytes = DecodeBase64Url(tokenParts[1]);
using var document = JsonDocument.Parse(payloadBytes);
if (!document.RootElement.TryGetProperty("realm_access", out var realmAccess) ||
!realmAccess.TryGetProperty("roles", out var rolesElement) ||
rolesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return rolesElement
.EnumerateArray()
.Select(role => role.GetString())
.Where(role => !string.IsNullOrWhiteSpace(role))
.Cast<string>()
.ToArray();
}
catch
{
return [];
}
}
private static byte[] DecodeBase64Url(string input)
{
var normalized = input.Replace('-', '+').Replace('_', '/');
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
return Convert.FromBase64String(normalized);
}
private string GetBaseUrl()
=> _options.BaseUrl.TrimEnd('/');
private string GetRealmBaseUrl()
=> $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.Realm)}";
private string GetAdminBaseUrl()
=> $"{GetBaseUrl()}/admin/realms/{Uri.EscapeDataString(_options.Realm)}";
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(content))
{
return default;
}
return JsonSerializer.Deserialize<T>(content, JsonOptions);
}
private sealed class TokenSuccessResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
}
private sealed class TokenErrorResponse
{
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; init; }
}
private sealed class UserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
}
private sealed class RealmRoleRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
}
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}