Inbound-API bearer credentials are no longer persisted in plaintext. ApiKey now holds a KeyHash (peppered HMAC-SHA256); the key is shown once at creation and only its hash is stored. Lookup and validation hash the presented candidate. Cross-module: Commons (ApiKey, ApiKeyHasher), ConfigurationDatabase (mapping + HashApiKeyValue migration), InboundAPI (ApiKeyValidator), ManagementService (key creation), CentralUI (ApiKeys.razor). Existing keys must be re-issued.
108 lines
5.4 KiB
C#
108 lines
5.4 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ScadaLink.Commons.Entities.InboundApi;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Types.InboundApi;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
|
|
|
public class InboundApiRepository : IInboundApiRepository
|
|
{
|
|
private readonly ScadaLinkDbContext _context;
|
|
private readonly ILogger<InboundApiRepository> _logger;
|
|
|
|
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
|
|
}
|
|
|
|
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
|
|
|
|
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
|
|
|
|
/// <summary>
|
|
/// ConfigurationDatabase-012: API keys are persisted only as a deterministic hash,
|
|
/// never as plaintext, so this lookup hashes the supplied plaintext value and
|
|
/// matches it against the stored <see cref="ApiKey.KeyHash"/> column. The
|
|
/// unpeppered default hasher is used here because the repository has no access to
|
|
/// the deployment pepper; the inbound-API authentication path does not use this
|
|
/// method — it loads all keys and compares hashes constant-time in
|
|
/// <c>ApiKeyValidator</c> with the configured (peppered) hasher.
|
|
/// </summary>
|
|
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
|
|
{
|
|
var keyHash = ApiKeyHasher.Default.Hash(keyValue);
|
|
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
|
|
}
|
|
|
|
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
|
|
|
|
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
|
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
|
|
|
|
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
|
|
if (entity != null) _context.Set<ApiKey>().Remove(entity);
|
|
}
|
|
|
|
public async Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiMethod>().FindAsync(new object[] { id }, cancellationToken);
|
|
|
|
public async Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiMethod>().ToListAsync(cancellationToken);
|
|
|
|
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
|
|
|
|
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
|
|
{
|
|
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
|
|
if (method?.ApprovedApiKeyIds == null)
|
|
return new List<ApiKey>();
|
|
|
|
// ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that
|
|
// fails to parse indicates a corrupt value: it is dropped (it cannot identify a key),
|
|
// but the corruption is logged as a warning so it is observable rather than silent.
|
|
// A corrupt list would otherwise quietly approve fewer keys than intended.
|
|
var keyIds = new List<int>();
|
|
foreach (var token in method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var trimmed = token.Trim();
|
|
if (int.TryParse(trimmed, out var id) && id > 0)
|
|
{
|
|
keyIds.Add(id);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"ApiMethod {MethodId} has a malformed approved-API-key id token '{Token}' " +
|
|
"in ApprovedApiKeyIds; it was dropped. The method may approve fewer keys than expected.",
|
|
methodId, trimmed);
|
|
}
|
|
}
|
|
|
|
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
|
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
|
|
|
|
public Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
|
{ _context.Set<ApiMethod>().Update(method); return Task.CompletedTask; }
|
|
|
|
public async Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var entity = await GetApiMethodByIdAsync(id, cancellationToken);
|
|
if (entity != null) _context.Set<ApiMethod>().Remove(entity);
|
|
}
|
|
|
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.SaveChangesAsync(cancellationToken);
|
|
}
|