Files
scadalink-design/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs
Joseph Doherty 9dccf8e72f deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
2026-04-08 15:56:23 -04:00

306 lines
11 KiB
C#

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
{
/// <summary>
/// Service for managing API keys with file-based storage.
/// Handles validation, role checking, and automatic reload on file changes.
/// </summary>
public class ApiKeyService : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyService>();
private readonly ConcurrentDictionary<string, ApiKey> _apiKeys;
private readonly string _configFilePath;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
private bool _disposed;
private FileSystemWatcher? _fileWatcher;
private DateTime _lastReloadTime = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyService" /> class.
/// </summary>
/// <param name="configFilePath">The path to the API key configuration file.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="configFilePath" /> is null.</exception>
public ApiKeyService(string configFilePath)
{
_configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath));
_apiKeys = new ConcurrentDictionary<string, ApiKey>();
InitializeFileWatcher();
LoadConfiguration();
}
/// <summary>
/// Disposes the <see cref="ApiKeyService" /> and releases resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_fileWatcher?.Dispose();
_reloadLock?.Dispose();
Logger.Information("API key service disposed");
}
/// <summary>
/// Validates an API key and returns its details if valid.
/// </summary>
/// <param name="apiKey">The API key value to validate.</param>
/// <returns>The <see cref="ApiKey" /> if valid; otherwise, <c>null</c>.</returns>
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;
}
/// <summary>
/// Checks if an API key has the specified role.
/// </summary>
/// <param name="apiKey">The API key value.</param>
/// <param name="requiredRole">The required <see cref="ApiKeyRole" />.</param>
/// <returns><c>true</c> if the API key has the required role; otherwise, <c>false</c>.</returns>
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;
}
/// <summary>
/// Initializes the file system watcher for the API key configuration file.
/// </summary>
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);
}
}
/// <summary>
/// Handles file change events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing event data.</param>
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());
}
}
/// <summary>
/// Handles file rename events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="RenamedEventArgs" /> instance containing event data.</param>
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());
}
}
/// <summary>
/// Asynchronously reloads the API key configuration from file.
/// Debounces rapid file changes to avoid excessive reloads.
/// </summary>
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();
}
}
/// <summary>
/// Loads the API key configuration from file.
/// If the file does not exist, creates a default configuration.
/// </summary>
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<ApiKeyConfiguration>(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);
}
}
/// <summary>
/// Creates a default API key configuration file with sample keys.
/// </summary>
private void CreateDefaultConfiguration()
{
try
{
var defaultConfig = new ApiKeyConfiguration
{
ApiKeys = new List<ApiKey>
{
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");
}
}
}
}