using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Serilog; namespace ZB.MOM.WW.LmxProxy.Host.Security { /// /// Service for managing API keys with file-based storage. /// Handles validation, role checking, and automatic reload on file changes. /// public class ApiKeyService : IDisposable { private static readonly ILogger Logger = Log.ForContext(); private readonly ConcurrentDictionary _apiKeys; private readonly string _configFilePath; private readonly SemaphoreSlim _reloadLock = new(1, 1); private bool _disposed; private FileSystemWatcher? _fileWatcher; private DateTime _lastReloadTime = DateTime.MinValue; /// /// Initializes a new instance of the class. /// /// The path to the API key configuration file. /// Thrown if is null. public ApiKeyService(string configFilePath) { _configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath)); _apiKeys = new ConcurrentDictionary(); InitializeFileWatcher(); LoadConfiguration(); } /// /// Disposes the and releases resources. /// public void Dispose() { if (_disposed) { return; } _disposed = true; _fileWatcher?.Dispose(); _reloadLock?.Dispose(); Logger.Information("API key service disposed"); } /// /// Validates an API key and returns its details if valid. /// /// The API key value to validate. /// The if valid; otherwise, null. public ApiKey? ValidateApiKey(string apiKey) { if (string.IsNullOrWhiteSpace(apiKey)) { return null; } if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid()) { Logger.Debug("API key validated successfully for {Description}", key.Description); return key; } Logger.Warning("Invalid or expired API key attempted"); return null; } /// /// Checks if an API key has the specified role. /// /// The API key value. /// The required . /// true if the API key has the required role; otherwise, false. public bool HasRole(string apiKey, ApiKeyRole requiredRole) { ApiKey? key = ValidateApiKey(apiKey); if (key == null) { return false; } // ReadWrite role has access to everything if (key.Role == ApiKeyRole.ReadWrite) { return true; } // ReadOnly role only has access to ReadOnly operations return requiredRole == ApiKeyRole.ReadOnly; } /// /// Initializes the file system watcher for the API key configuration file. /// private void InitializeFileWatcher() { string? directory = Path.GetDirectoryName(_configFilePath); string? fileName = Path.GetFileName(_configFilePath); if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) { Logger.Warning("Invalid config file path, file watching disabled"); return; } try { _fileWatcher = new FileSystemWatcher(directory, fileName) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, EnableRaisingEvents = true }; _fileWatcher.Changed += OnFileChanged; _fileWatcher.Created += OnFileChanged; _fileWatcher.Renamed += OnFileRenamed; Logger.Information("File watcher initialized for {FilePath}", _configFilePath); } catch (Exception ex) { Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath); } } /// /// Handles file change events for the configuration file. /// /// The event sender. /// The instance containing event data. private void OnFileChanged(object sender, FileSystemEventArgs e) { if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created) { Logger.Information("API key configuration file changed, reloading"); Task.Run(() => ReloadConfigurationAsync()); } } /// /// Handles file rename events for the configuration file. /// /// The event sender. /// The instance containing event data. private void OnFileRenamed(object sender, RenamedEventArgs e) { if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase)) { Logger.Information("API key configuration file renamed, reloading"); Task.Run(() => ReloadConfigurationAsync()); } } /// /// Asynchronously reloads the API key configuration from file. /// Debounces rapid file changes to avoid excessive reloads. /// private async Task ReloadConfigurationAsync() { // Debounce rapid file changes TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime; if (timeSinceLastReload < TimeSpan.FromSeconds(1)) { await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload); } await _reloadLock.WaitAsync(); try { LoadConfiguration(); _lastReloadTime = DateTime.UtcNow; } finally { _reloadLock.Release(); } } /// /// Loads the API key configuration from file. /// If the file does not exist, creates a default configuration. /// private void LoadConfiguration() { try { if (!File.Exists(_configFilePath)) { Logger.Warning("API key configuration file not found at {FilePath}, creating default", _configFilePath); CreateDefaultConfiguration(); return; } string json = File.ReadAllText(_configFilePath); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip }; options.Converters.Add(new JsonStringEnumConverter()); ApiKeyConfiguration? config = JsonSerializer.Deserialize(json, options); if (config?.ApiKeys == null || !config.ApiKeys.Any()) { Logger.Warning("No API keys found in configuration file"); return; } // Clear existing keys and load new ones _apiKeys.Clear(); foreach (ApiKey? apiKey in config.ApiKeys) { if (string.IsNullOrWhiteSpace(apiKey.Key)) { Logger.Warning("Skipping API key with empty key value"); continue; } if (_apiKeys.TryAdd(apiKey.Key, apiKey)) { Logger.Information("Loaded API key: {Description} with role {Role}", apiKey.Description, apiKey.Role); } else { Logger.Warning("Duplicate API key found: {Description}", apiKey.Description); } } Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count); } catch (Exception ex) { Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath); } } /// /// Creates a default API key configuration file with sample keys. /// private void CreateDefaultConfiguration() { try { var defaultConfig = new ApiKeyConfiguration { ApiKeys = new List { new() { Key = Guid.NewGuid().ToString("N"), Description = "Default read-only API key", Role = ApiKeyRole.ReadOnly, Enabled = true }, new() { Key = Guid.NewGuid().ToString("N"), Description = "Default read-write API key", Role = ApiKeyRole.ReadWrite, Enabled = true } } }; string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions { WriteIndented = true }); string? directory = Path.GetDirectoryName(_configFilePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } File.WriteAllText(_configFilePath, json); Logger.Information("Created default API key configuration at {FilePath}", _configFilePath); // Load the created configuration LoadConfiguration(); } catch (Exception ex) { Logger.Error(ex, "Failed to create default API key configuration"); } } } }