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.
306 lines
11 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|