181 lines
6.7 KiB
C#
181 lines
6.7 KiB
C#
using System.Text.Json;
|
|
|
|
namespace MxGateway.Server.Security.Authentication;
|
|
|
|
public sealed class ApiKeyAdminCliRunner(
|
|
IAuthStoreMigrator migrator,
|
|
IApiKeyAdminStore adminStore,
|
|
IApiKeyAuditStore auditStore,
|
|
IApiKeySecretHasher hasher)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
|
|
public async Task<int> RunAsync(
|
|
ApiKeyAdminCommand command,
|
|
TextWriter output,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ApiKeyAdminOutput result = command.Kind switch
|
|
{
|
|
ApiKeyAdminCommandKind.InitDb => await InitDbAsync(cancellationToken).ConfigureAwait(false),
|
|
ApiKeyAdminCommandKind.CreateKey => await CreateKeyAsync(command, cancellationToken).ConfigureAwait(false),
|
|
ApiKeyAdminCommandKind.ListKeys => await ListKeysAsync(cancellationToken).ConfigureAwait(false),
|
|
ApiKeyAdminCommandKind.RevokeKey => await RevokeKeyAsync(command, cancellationToken).ConfigureAwait(false),
|
|
ApiKeyAdminCommandKind.RotateKey => await RotateKeyAsync(command, cancellationToken).ConfigureAwait(false),
|
|
_ => throw new InvalidOperationException($"Unsupported API key command '{command.Kind}'.")
|
|
};
|
|
|
|
await WriteOutputAsync(command, result, output).ConfigureAwait(false);
|
|
|
|
return 0;
|
|
}
|
|
|
|
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
|
|
{
|
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
|
|
}
|
|
|
|
private async Task<ApiKeyAdminOutput> CreateKeyAsync(
|
|
ApiKeyAdminCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
string keyId = Required(command.KeyId);
|
|
string secret = ApiKeySecretGenerator.Generate();
|
|
string apiKey = FormatApiKey(keyId, secret);
|
|
|
|
await adminStore.CreateAsync(
|
|
new ApiKeyCreateRequest(
|
|
KeyId: keyId,
|
|
KeyPrefix: $"mxgw_{keyId}",
|
|
SecretHash: hasher.HashSecret(secret),
|
|
DisplayName: Required(command.DisplayName),
|
|
Scopes: command.Scopes,
|
|
CreatedUtc: DateTimeOffset.UtcNow),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
|
|
}
|
|
|
|
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
|
|
{
|
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
IReadOnlyList<ApiKeyRecord> keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false);
|
|
await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new ApiKeyAdminOutput(
|
|
"list-keys",
|
|
"ok",
|
|
null,
|
|
keys.Select(ToListedKey).ToArray());
|
|
}
|
|
|
|
private async Task<ApiKeyAdminOutput> RevokeKeyAsync(
|
|
ApiKeyAdminCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
string keyId = Required(command.KeyId);
|
|
bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []);
|
|
}
|
|
|
|
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
|
|
ApiKeyAdminCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
string keyId = Required(command.KeyId);
|
|
string secret = ApiKeySecretGenerator.Generate();
|
|
string apiKey = FormatApiKey(keyId, secret);
|
|
|
|
bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
|
|
}
|
|
|
|
private static async Task WriteOutputAsync(
|
|
ApiKeyAdminCommand command,
|
|
ApiKeyAdminOutput result,
|
|
TextWriter output)
|
|
{
|
|
if (command.Json)
|
|
{
|
|
await output.WriteLineAsync(JsonSerializer.Serialize(result, JsonOptions)).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
await output.WriteLineAsync($"{result.Command}: {result.Status}").ConfigureAwait(false);
|
|
|
|
if (result.ApiKey is not null)
|
|
{
|
|
await output.WriteLineAsync($"API key: {result.ApiKey}").ConfigureAwait(false);
|
|
}
|
|
|
|
foreach (ApiKeyAdminListedKey key in result.Keys)
|
|
{
|
|
string revoked = key.RevokedUtc is null ? "active" : "revoked";
|
|
await output.WriteLineAsync($"{key.KeyId}\t{key.DisplayName}\t{revoked}\t{string.Join(',', key.Scopes)}")
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task AppendAuditAsync(
|
|
string? keyId,
|
|
string eventType,
|
|
string? details,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await auditStore.AppendAsync(
|
|
new ApiKeyAuditEntry(
|
|
KeyId: keyId,
|
|
EventType: eventType,
|
|
RemoteAddress: null,
|
|
Details: details),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key)
|
|
{
|
|
return new ApiKeyAdminListedKey(
|
|
KeyId: key.KeyId,
|
|
KeyPrefix: key.KeyPrefix,
|
|
DisplayName: key.DisplayName,
|
|
Scopes: key.Scopes,
|
|
CreatedUtc: key.CreatedUtc,
|
|
LastUsedUtc: key.LastUsedUtc,
|
|
RevokedUtc: key.RevokedUtc);
|
|
}
|
|
|
|
private static string FormatApiKey(string keyId, string secret)
|
|
{
|
|
return $"mxgw_{keyId}_{secret}";
|
|
}
|
|
|
|
private static string Required(string? value)
|
|
{
|
|
return value ?? throw new InvalidOperationException("Required command value was not provided.");
|
|
}
|
|
}
|