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.
184 lines
6.4 KiB
C#
184 lines
6.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using Newtonsoft.Json;
|
|
using Serilog;
|
|
|
|
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
|
{
|
|
/// <summary>
|
|
/// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher.
|
|
/// </summary>
|
|
public sealed class ApiKeyService : IDisposable
|
|
{
|
|
private static readonly ILogger Log = Serilog.Log.ForContext<ApiKeyService>();
|
|
|
|
private readonly string _configFilePath;
|
|
private readonly FileSystemWatcher? _watcher;
|
|
private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1);
|
|
private volatile Dictionary<string, ApiKey> _keys = new Dictionary<string, ApiKey>(StringComparer.Ordinal);
|
|
private DateTime _lastReloadTime = DateTime.MinValue;
|
|
private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1);
|
|
|
|
public ApiKeyService(string configFilePath)
|
|
{
|
|
_configFilePath = Path.GetFullPath(configFilePath);
|
|
|
|
// Auto-generate default file if missing
|
|
if (!File.Exists(_configFilePath))
|
|
{
|
|
GenerateDefaultKeyFile();
|
|
}
|
|
|
|
// Initial load
|
|
LoadKeys();
|
|
|
|
// Set up FileSystemWatcher for hot-reload
|
|
var directory = Path.GetDirectoryName(_configFilePath);
|
|
var fileName = Path.GetFileName(_configFilePath);
|
|
if (directory != null)
|
|
{
|
|
_watcher = new FileSystemWatcher(directory, fileName)
|
|
{
|
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
|
|
EnableRaisingEvents = true
|
|
};
|
|
_watcher.Changed += OnFileChanged;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise.
|
|
/// </summary>
|
|
public ApiKey? ValidateApiKey(string apiKey)
|
|
{
|
|
if (string.IsNullOrEmpty(apiKey)) return null;
|
|
return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a key has the required role.
|
|
/// ReadWrite implies ReadOnly.
|
|
/// </summary>
|
|
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
|
|
{
|
|
var key = ValidateApiKey(apiKey);
|
|
if (key == null) return false;
|
|
|
|
switch (requiredRole)
|
|
{
|
|
case ApiKeyRole.ReadOnly:
|
|
return true; // Both roles have ReadOnly
|
|
case ApiKeyRole.ReadWrite:
|
|
return key.Role == ApiKeyRole.ReadWrite;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Gets the count of loaded API keys.</summary>
|
|
public int KeyCount => _keys.Count;
|
|
|
|
private void GenerateDefaultKeyFile()
|
|
{
|
|
Log.Information("API key file not found at {Path}, generating defaults", _configFilePath);
|
|
|
|
var config = new ApiKeyConfiguration
|
|
{
|
|
ApiKeys = new List<ApiKey>
|
|
{
|
|
new ApiKey
|
|
{
|
|
Key = GenerateRandomKey(),
|
|
Description = "Default ReadOnly key (auto-generated)",
|
|
Role = ApiKeyRole.ReadOnly,
|
|
Enabled = true
|
|
},
|
|
new ApiKey
|
|
{
|
|
Key = GenerateRandomKey(),
|
|
Description = "Default ReadWrite key (auto-generated)",
|
|
Role = ApiKeyRole.ReadWrite,
|
|
Enabled = true
|
|
}
|
|
}
|
|
};
|
|
|
|
var directory = Path.GetDirectoryName(_configFilePath);
|
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
|
|
File.WriteAllText(_configFilePath, json);
|
|
Log.Information("Default API key file generated at {Path}", _configFilePath);
|
|
}
|
|
|
|
private static string GenerateRandomKey()
|
|
{
|
|
// 32 random bytes -> 64-char hex string
|
|
var bytes = new byte[32];
|
|
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
|
|
{
|
|
rng.GetBytes(bytes);
|
|
}
|
|
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
|
|
}
|
|
|
|
private void LoadKeys()
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(_configFilePath);
|
|
var config = JsonConvert.DeserializeObject<ApiKeyConfiguration>(json);
|
|
if (config?.ApiKeys != null)
|
|
{
|
|
_keys = config.ApiKeys
|
|
.Where(k => !string.IsNullOrEmpty(k.Key))
|
|
.ToDictionary(k => k.Key, k => k, StringComparer.Ordinal);
|
|
Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("API key file at {Path} contained no keys", _configFilePath);
|
|
_keys = new Dictionary<string, ApiKey>(StringComparer.Ordinal);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath);
|
|
}
|
|
}
|
|
|
|
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
|
{
|
|
// Debounce: ignore rapid changes within 1 second
|
|
if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return;
|
|
|
|
if (_reloadLock.Wait(0))
|
|
{
|
|
try
|
|
{
|
|
_lastReloadTime = DateTime.UtcNow;
|
|
Log.Information("API key file changed, reloading");
|
|
|
|
// Small delay to let the file system finish writing
|
|
Thread.Sleep(100);
|
|
LoadKeys();
|
|
}
|
|
finally
|
|
{
|
|
_reloadLock.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_watcher?.Dispose();
|
|
_reloadLock.Dispose();
|
|
}
|
|
}
|
|
}
|