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
{
///
/// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher.
///
public sealed class ApiKeyService : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext();
private readonly string _configFilePath;
private readonly FileSystemWatcher? _watcher;
private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1);
private volatile Dictionary _keys = new Dictionary(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;
}
}
///
/// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise.
///
public ApiKey? ValidateApiKey(string apiKey)
{
if (string.IsNullOrEmpty(apiKey)) return null;
return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null;
}
///
/// Checks if a key has the required role.
/// ReadWrite implies ReadOnly.
///
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;
}
}
/// Gets the count of loaded API keys.
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
{
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(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(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();
}
}
}