151 lines
6.3 KiB
C#
151 lines
6.3 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Identity.Client;
|
|
using UniVerse.Application.Interfaces;
|
|
using UniVerse.Domain.Exceptions;
|
|
|
|
namespace UniVerse.Infrastructure.ExternalServices;
|
|
|
|
public class MicrosoftAuthClient : IMicrosoftAuthClient
|
|
{
|
|
private readonly IConfiguration _config;
|
|
private readonly EntraTokenCacheStore _cache;
|
|
private readonly HttpClient _http;
|
|
private readonly ILogger<MicrosoftAuthClient> _logger;
|
|
|
|
public MicrosoftAuthClient(IConfiguration config, EntraTokenCacheStore cache, HttpClient http, ILogger<MicrosoftAuthClient> logger)
|
|
{
|
|
_config = config;
|
|
_cache = cache;
|
|
_http = http;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
|
string authorizationCode,
|
|
string redirectUri,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var app = CreateClient(redirectUri);
|
|
byte[]? serializedCache = null;
|
|
app.UserTokenCache.SetAfterAccess(args =>
|
|
{
|
|
if (args.HasStateChanged) serializedCache = args.TokenCache.SerializeMsalV3();
|
|
});
|
|
|
|
try
|
|
{
|
|
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
|
|
.ExecuteAsync(cancellationToken);
|
|
|
|
return new MicrosoftTokenResult(
|
|
result.IdToken,
|
|
serializedCache,
|
|
result.Account.HomeAccountId.Identifier);
|
|
}
|
|
catch (MsalException ex)
|
|
{
|
|
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public Task SaveTokenCacheAsync(int userId, MicrosoftTokenResult result, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_cache.IsConfigured || result.TokenCache is null || string.IsNullOrWhiteSpace(result.AccountId))
|
|
return Task.CompletedTask;
|
|
return _cache.LockedAsync(userId, async () =>
|
|
{
|
|
await _cache.SaveAsync(userId, result.AccountId, result.TokenCache, cancellationToken);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public async Task<MicrosoftPhotoResult?> GetPhotoAsync(int userId, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_cache.IsConfigured) return null;
|
|
string? accessToken;
|
|
try
|
|
{
|
|
accessToken = await _cache.LockedAsync(userId, async () =>
|
|
{
|
|
var saved = await _cache.ReadAsync(userId, cancellationToken);
|
|
if (saved is null) return null;
|
|
|
|
var app = CreateClient();
|
|
byte[]? updatedCache = null;
|
|
app.UserTokenCache.SetBeforeAccess(args => args.TokenCache.DeserializeMsalV3(saved.Cache));
|
|
app.UserTokenCache.SetAfterAccess(args =>
|
|
{
|
|
if (args.HasStateChanged) updatedCache = args.TokenCache.SerializeMsalV3();
|
|
});
|
|
var account = await app.GetAccountAsync(saved.AccountId);
|
|
if (account is null) return null;
|
|
|
|
var result = await app.AcquireTokenSilent(["User.Read"], account)
|
|
.ExecuteAsync(cancellationToken);
|
|
if (updatedCache is not null)
|
|
await _cache.SaveAsync(userId, saved.AccountId, updatedCache, cancellationToken);
|
|
return result.AccessToken;
|
|
});
|
|
}
|
|
catch (MsalUiRequiredException)
|
|
{
|
|
_logger.LogInformation("Microsoft requires a new sign-in before the profile photo can be loaded for user {UserId}.", userId);
|
|
return null;
|
|
}
|
|
catch (Exception ex) when (ex is MsalException or IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException or JsonException)
|
|
{
|
|
_logger.LogWarning(ex, "Cannot read Microsoft token cache for user {UserId}.", userId);
|
|
return null;
|
|
}
|
|
if (accessToken is null) return null;
|
|
|
|
try
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value");
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var contentType = response.Content.Headers.ContentType?.MediaType;
|
|
if (contentType is not ("image/jpeg" or "image/png" or "image/gif" or "image/webp"))
|
|
return null;
|
|
await response.Content.LoadIntoBufferAsync(5 * 1024 * 1024, cancellationToken);
|
|
return new MicrosoftPhotoResult(await response.Content.ReadAsByteArrayAsync(cancellationToken), contentType);
|
|
}
|
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "Cannot load Microsoft profile photo for user {UserId}.", userId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public Task RemoveTokenCacheAsync(int userId, CancellationToken cancellationToken = default) =>
|
|
_cache.LockedAsync(userId, async () =>
|
|
{
|
|
await _cache.RemoveAsync(userId);
|
|
return true;
|
|
});
|
|
|
|
private IConfidentialClientApplication CreateClient(string? redirectUri = null)
|
|
{
|
|
var tenantId = _config["AzureAd:TenantId"];
|
|
var clientId = _config["AzureAd:ClientId"];
|
|
var clientSecret = _config["AzureAd:ClientSecret"];
|
|
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId)
|
|
|| string.IsNullOrWhiteSpace(clientId)
|
|
|| string.IsNullOrWhiteSpace(clientSecret))
|
|
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
|
|
|
var builder = ConfidentialClientApplicationBuilder.Create(clientId)
|
|
.WithClientSecret(clientSecret)
|
|
.WithAuthority(new Uri($"{instance.TrimEnd('/')}/{tenantId}"));
|
|
if (redirectUri is not null) builder = builder.WithRedirectUri(redirectUri);
|
|
return builder.Build();
|
|
}
|
|
}
|