93 lines
4.0 KiB
C#
93 lines
4.0 KiB
C#
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);
|
|
}
|