using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Configuration; namespace UniVerse.Infrastructure.ExternalServices; /// Зашифрованный кэш токенов MSAL. Ключ берётся только из конфигурации сервера. public sealed class EntraTokenCacheStore { private readonly string _directory; private readonly byte[]? _key; private readonly ConcurrentDictionary _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 LockedAsync(int userId, Func> 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 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(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); }