543 lines
20 KiB
C#
543 lines
20 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ChessCubing.Server.Admin;
|
|
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 static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
};
|
|
|
|
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.Username,
|
|
request.Email,
|
|
request.FirstName,
|
|
request.LastName,
|
|
isEnabled: true,
|
|
isEmailVerified: false,
|
|
cancellationToken);
|
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
|
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
|
}
|
|
|
|
public async Task<AdminIdentityUser> CreateAdminUserAsync(
|
|
AdminIdentityUserCreateRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
|
var userId = await CreateUserAsync(
|
|
adminToken,
|
|
request.Username,
|
|
request.Email,
|
|
request.FirstName,
|
|
request.LastName,
|
|
request.IsEnabled,
|
|
request.IsEmailVerified,
|
|
cancellationToken);
|
|
|
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
|
|
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
|
{
|
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
|
var users = new List<AdminIdentityUser>();
|
|
const int pageSize = 100;
|
|
|
|
for (var first = 0; ; first += pageSize)
|
|
{
|
|
using var request = new HttpRequestMessage(
|
|
HttpMethod.Get,
|
|
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
|
|
}
|
|
|
|
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
|
|
users.AddRange(page
|
|
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
|
|
.Select(MapAdminIdentityUser));
|
|
|
|
if (page.Count < pageSize)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return users;
|
|
}
|
|
|
|
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
|
}
|
|
|
|
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
|
|
string userId,
|
|
AdminIdentityUserUpdateRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
|
|
|
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
|
|
{
|
|
Content = JsonContent.Create(new
|
|
{
|
|
username = request.Username,
|
|
email = request.Email,
|
|
enabled = request.IsEnabled,
|
|
emailVerified = request.IsEmailVerified,
|
|
firstName = request.FirstName,
|
|
lastName = request.LastName,
|
|
}, options: UpdateJsonOptions)
|
|
})
|
|
{
|
|
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.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
|
|
}
|
|
}
|
|
|
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
|
}
|
|
|
|
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new KeycloakAuthException("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
|
|
}
|
|
}
|
|
|
|
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,
|
|
string username,
|
|
string? email,
|
|
string? firstName,
|
|
string? lastName,
|
|
bool isEnabled,
|
|
bool isEmailVerified,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
|
|
{
|
|
Content = JsonContent.Create(new
|
|
{
|
|
username = username.Trim(),
|
|
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
|
enabled = isEnabled,
|
|
emailVerified = isEmailVerified,
|
|
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
|
|
lastName = string.IsNullOrWhiteSpace(lastName) ? null : 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, 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 async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new KeycloakAuthException("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
|
|
}
|
|
|
|
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
|
|
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
|
|
{
|
|
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
|
|
}
|
|
|
|
return MapAdminIdentityUser(user);
|
|
}
|
|
|
|
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
|
|
=> new(
|
|
user.Id!,
|
|
user.Username!,
|
|
user.Email,
|
|
user.FirstName,
|
|
user.LastName,
|
|
user.Enabled ?? true,
|
|
user.EmailVerified ?? false,
|
|
user.CreatedTimestamp is > 0
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
|
|
: null);
|
|
|
|
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; }
|
|
}
|
|
|
|
private sealed class AdminUserRepresentation
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public string? Id { get; init; }
|
|
|
|
[JsonPropertyName("username")]
|
|
public string? Username { get; init; }
|
|
|
|
[JsonPropertyName("email")]
|
|
public string? Email { get; init; }
|
|
|
|
[JsonPropertyName("firstName")]
|
|
public string? FirstName { get; init; }
|
|
|
|
[JsonPropertyName("lastName")]
|
|
public string? LastName { get; init; }
|
|
|
|
[JsonPropertyName("enabled")]
|
|
public bool? Enabled { get; init; }
|
|
|
|
[JsonPropertyName("emailVerified")]
|
|
public bool? EmailVerified { get; init; }
|
|
|
|
[JsonPropertyName("createdTimestamp")]
|
|
public long? CreatedTimestamp { get; init; }
|
|
}
|
|
}
|
|
|
|
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
|
|
{
|
|
public int StatusCode { get; } = statusCode;
|
|
}
|