135 lines
4.5 KiB
C#
135 lines
4.5 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using w4c_workflows.Data;
|
|
using w4c_workflows.Models;
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
/// <summary>Resolved identity of an operator-key bearer.</summary>
|
|
public sealed record OperatorPrincipal(Guid KeyId, string TenantId, IReadOnlyList<string> Scopes)
|
|
{
|
|
public bool HasScope(string scope) => Scopes.Contains(scope, StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mints, verifies, lists and revokes per-tenant operator API keys. Only the
|
|
/// SHA-256 hash is persisted; the raw key is returned exactly once at mint time.
|
|
/// </summary>
|
|
public class ApiKeyService
|
|
{
|
|
private const string KeyPrefix = "w4c_";
|
|
|
|
/// <summary>
|
|
/// <c>LastUsedAt</c> is informational, so it is written at most this often
|
|
/// per key instead of on every authenticated request (which doubled the DB
|
|
/// work on the auth hot path).
|
|
/// </summary>
|
|
private static readonly TimeSpan LastUsedWriteInterval = TimeSpan.FromMinutes(5);
|
|
|
|
private readonly WorkflowsDbContext _db;
|
|
|
|
public ApiKeyService(WorkflowsDbContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a fresh operator key, stores its hash, and returns the raw key
|
|
/// (shown to the caller once — never persisted).
|
|
/// </summary>
|
|
public async Task<(Guid Id, string RawKey)> MintAsync(
|
|
string tenantId, string label, IReadOnlyList<string> scopes, CancellationToken ct)
|
|
{
|
|
var raw = KeyPrefix + Base64Url(RandomNumberGenerator.GetBytes(32));
|
|
|
|
var key = new ApiKey
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = tenantId,
|
|
KeyHash = Hash(raw),
|
|
Label = label,
|
|
ScopesJson = JsonSerializer.Serialize(scopes),
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
_db.ApiKeys.Add(key);
|
|
await _db.SaveChangesAsync(ct);
|
|
return (key.Id, raw);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a bearer token to an active key's tenant + scopes, touching
|
|
/// last_used_at. Returns null for unknown/revoked keys (caller → 401).
|
|
/// </summary>
|
|
public async Task<OperatorPrincipal?> VerifyAsync(string? authHeader, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrEmpty(authHeader) ||
|
|
!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
return null;
|
|
|
|
var raw = authHeader["Bearer ".Length..].Trim();
|
|
if (string.IsNullOrEmpty(raw))
|
|
return null;
|
|
|
|
var hash = Hash(raw);
|
|
var key = await _db.ApiKeys
|
|
.FirstOrDefaultAsync(k => k.KeyHash == hash && k.RevokedAt == null, ct);
|
|
if (key == null)
|
|
return null;
|
|
|
|
// Throttled: skip the write when the key was already seen recently.
|
|
var now = DateTime.UtcNow;
|
|
if (key.LastUsedAt is null || now - key.LastUsedAt.Value >= LastUsedWriteInterval)
|
|
{
|
|
key.LastUsedAt = now;
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
var scopes = ParseScopes(key.ScopesJson);
|
|
return new OperatorPrincipal(key.Id, key.TenantId, scopes);
|
|
}
|
|
|
|
public Task<List<ApiKey>> ListAsync(string tenantId, CancellationToken ct)
|
|
=> _db.ApiKeys
|
|
.Where(k => k.TenantId == tenantId)
|
|
.OrderByDescending(k => k.CreatedAt)
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<bool> RevokeAsync(string tenantId, Guid id, CancellationToken ct)
|
|
{
|
|
var key = await _db.ApiKeys
|
|
.FirstOrDefaultAsync(k => k.Id == id && k.TenantId == tenantId, ct);
|
|
if (key == null)
|
|
return false;
|
|
|
|
key.RevokedAt ??= DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public Task<int> CountActiveAsync(string tenantId, CancellationToken ct)
|
|
=> _db.ApiKeys.CountAsync(k => k.TenantId == tenantId && k.RevokedAt == null, ct);
|
|
|
|
internal static string Hash(string raw) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
|
|
|
|
private static IReadOnlyList<string> ParseScopes(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
return Array.Empty<string>();
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
|
|
}
|
|
catch
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
}
|
|
|
|
private static string Base64Url(byte[] bytes) =>
|
|
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|