feat: добавил отображение аватарки
gitea-push-docker.yml (push) Invalid workflow file
Backend CI / build-and-test (push) Successful in 3m34s
Frontend CI / build-and-check (push) Failing after 21s

This commit is contained in:
serega404 committed 2026-10-01 00:17:16 +03:00
1 parent 34e450815b
commit 038f0d89a6
22 files changed
+598 -28

No files matched your search

@@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
namespace UniVerse.Infrastructure.ExternalServices;
/// <summary>Зашифрованный кэш токенов MSAL. Ключ берётся только из конфигурации сервера.</summary>
public sealed class EntraTokenCacheStore
{
private readonly string _directory;
private readonly byte[]? _key;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _locks = new();
public EntraTokenCacheStore(IConfiguration configuration)
{
_directory = configuration["AzureAd:TokenCacheDirectory"]
?? Path.Combine(Path.GetTempPath(), "universe-entra-token-cache");
var encodedKey = configuration["AzureAd:TokenCacheKey"];
if (!string.IsNullOrWhiteSpace(encodedKey))
{
try { _key = Convert.FromBase64String(encodedKey); }
catch (FormatException ex) { throw new InvalidOperationException("AzureAd:TokenCacheKey must be a base64-encoded 32-byte key.", ex); }
if (_key.Length != 32)
throw new InvalidOperationException("AzureAd:TokenCacheKey must be a base64-encoded 32-byte key.");
}
}
public bool IsConfigured => _key is not null;
public async Task<T> LockedAsync<T>(int userId, Func<Task<T>> action)
{
var gate = _locks.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
try { return await action(); }
finally { gate.Release(); }
}
public async Task SaveAsync(int userId, string accountId, byte[] cache, CancellationToken cancellationToken)
{
if (_key is null) return;
Directory.CreateDirectory(_directory);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
var payload = JsonSerializer.SerializeToUtf8Bytes(new CachedTokens(accountId, cache));
var nonce = RandomNumberGenerator.GetBytes(12);
var ciphertext = new byte[payload.Length];
var tag = new byte[16];
using (var aes = new AesGcm(_key, tag.Length))
aes.Encrypt(nonce, payload, ciphertext, tag);
var encrypted = new byte[nonce.Length + tag.Length + ciphertext.Length];
nonce.CopyTo(encrypted, 0);
tag.CopyTo(encrypted, nonce.Length);
ciphertext.CopyTo(encrypted, nonce.Length + tag.Length);
var temporaryPath = Path.Combine(_directory, $".user-{userId}-{Guid.NewGuid():N}.tmp");
try
{
await File.WriteAllBytesAsync(temporaryPath, encrypted, cancellationToken);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(temporaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
File.Move(temporaryPath, FilePath(userId), true);
}
finally
{
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
}
}
public async Task<CachedTokens?> ReadAsync(int userId, CancellationToken cancellationToken)
{
if (_key is null || !File.Exists(FilePath(userId))) return null;
var encrypted = await File.ReadAllBytesAsync(FilePath(userId), cancellationToken);
if (encrypted.Length < 28) throw new CryptographicException("Invalid Entra token cache data.");
var plaintext = new byte[encrypted.Length - 28];
using (var aes = new AesGcm(_key, 16))
aes.Decrypt(encrypted.AsSpan(0, 12), encrypted.AsSpan(28), encrypted.AsSpan(12, 16), plaintext);
return JsonSerializer.Deserialize<CachedTokens>(plaintext);
}
public Task RemoveAsync(int userId)
{
var path = FilePath(userId);
if (File.Exists(path)) File.Delete(path);
return Task.CompletedTask;
}
private string FilePath(int userId) => Path.Combine(_directory, $"user-{userId}.bin");
public sealed record CachedTokens(string AccountId, byte[] Cache);
}
@@ -1,4 +1,7 @@
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;
@@ -8,16 +11,125 @@ 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)
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"];
@@ -29,24 +141,10 @@ public class MicrosoftAuthClient : IMicrosoftAuthClient
|| string.IsNullOrWhiteSpace(clientSecret))
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
var app = ConfidentialClientApplicationBuilder.Create(clientId)
var builder = ConfidentialClientApplicationBuilder.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority(new Uri(authority))
.WithRedirectUri(redirectUri)
.Build();
try
{
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
.ExecuteAsync(cancellationToken);
return new MicrosoftTokenResult(result.IdToken);
}
catch (MsalException ex)
{
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
}
.WithAuthority(new Uri($"{instance.TrimEnd('/')}/{tenantId}"));
if (redirectUri is not null) builder = builder.WithRedirectUri(redirectUri);
return builder.Build();
}
}
@@ -108,6 +108,18 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync();
}
try
{
if (string.IsNullOrWhiteSpace(user.AvatarUrl))
await _microsoftAuth.SaveTokenCacheAsync(user.Id, result);
else
await _microsoftAuth.RemoveTokenCacheAsync(user.Id);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException)
{
_logger.LogWarning(ex, "Could not save Microsoft token cache for user {UserId}.", user.Id);
}
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
await TrySendLoginNotificationAsync(user, ipAddress);
@@ -215,6 +227,18 @@ public class AuthService : IAuthService
}
}
public async Task RemoveMicrosoftTokenCacheAsync(int userId)
{
try
{
await _microsoftAuth.RemoveTokenCacheAsync(userId);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not remove Microsoft token cache for user {UserId}.", userId);
}
}
public async Task<CurrentUserDto> GetCurrentUserAsync(int userId)
{
var user = await _db.Users