615b487a77
Adds missing <summary>/<param> XML docs across 99 server, worker, and test files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the analyzer clean). Bundles in WIP dashboard work: NavSection extraction, MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
257 lines
9.3 KiB
C#
257 lines
9.3 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
public sealed class DashboardApiKeyManagementService(
|
|
DashboardApiKeyAuthorization authorization,
|
|
IApiKeyAdminStore adminStore,
|
|
IApiKeyAuditStore auditStore,
|
|
IApiKeySecretHasher hasher,
|
|
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
|
{
|
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
|
|
|
/// <summary>Determines whether the user can manage API keys.</summary>
|
|
/// <param name="user">The authenticated user principal.</param>
|
|
public bool CanManage(ClaimsPrincipal user)
|
|
{
|
|
return authorization.CanManage(user);
|
|
}
|
|
|
|
/// <summary>Creates an API key asynchronously.</summary>
|
|
/// <param name="user">The authenticated user principal.</param>
|
|
/// <param name="request">The request payload.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
|
ClaimsPrincipal user,
|
|
DashboardApiKeyManagementRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
string? validation = ValidateCreateRequest(request);
|
|
if (validation is not null)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(validation);
|
|
}
|
|
|
|
string keyId = request.KeyId.Trim();
|
|
string secret = ApiKeySecretGenerator.Generate();
|
|
string apiKey = FormatApiKey(keyId, secret);
|
|
|
|
try
|
|
{
|
|
await adminStore.CreateAsync(
|
|
new ApiKeyCreateRequest(
|
|
KeyId: keyId,
|
|
KeyPrefix: $"mxgw_{keyId}",
|
|
SecretHash: hasher.HashSecret(secret),
|
|
DisplayName: request.DisplayName.Trim(),
|
|
Scopes: request.Scopes,
|
|
Constraints: request.Constraints,
|
|
CreatedUtc: DateTimeOffset.UtcNow),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
|
|
|
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
|
|
}
|
|
catch (ApiKeyPepperUnavailableException)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
|
}
|
|
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail("An API key with that id already exists.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Revokes an API key asynchronously.</summary>
|
|
/// <param name="user">The authenticated user principal.</param>
|
|
/// <param name="keyId">The API key identifier.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
|
ClaimsPrincipal user,
|
|
string keyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
string? validation = ValidateKeyId(keyId);
|
|
if (validation is not null)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(validation);
|
|
}
|
|
|
|
string normalizedKeyId = keyId.Trim();
|
|
bool revoked = await adminStore
|
|
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(
|
|
normalizedKeyId,
|
|
"dashboard-revoke-key",
|
|
revoked ? "revoked" : "not-found-or-already-revoked",
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return revoked
|
|
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
|
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
|
}
|
|
|
|
/// <summary>Rotates an API key secret asynchronously.</summary>
|
|
/// <param name="user">The authenticated user principal.</param>
|
|
/// <param name="keyId">The API key identifier.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
|
ClaimsPrincipal user,
|
|
string keyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
string? validation = ValidateKeyId(keyId);
|
|
if (validation is not null)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(validation);
|
|
}
|
|
|
|
string normalizedKeyId = keyId.Trim();
|
|
string secret = ApiKeySecretGenerator.Generate();
|
|
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
|
|
|
try
|
|
{
|
|
bool rotated = await adminStore
|
|
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(
|
|
normalizedKeyId,
|
|
"dashboard-rotate-key",
|
|
rotated ? "rotated" : "not-found",
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return rotated
|
|
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
|
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
|
}
|
|
catch (ApiKeyPepperUnavailableException)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Deletes a revoked API key asynchronously.</summary>
|
|
/// <param name="user">The authenticated user principal.</param>
|
|
/// <param name="keyId">The API key identifier.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
|
ClaimsPrincipal user,
|
|
string keyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanManage(user))
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
|
}
|
|
|
|
string? validation = ValidateKeyId(keyId);
|
|
if (validation is not null)
|
|
{
|
|
return DashboardApiKeyManagementResult.Fail(validation);
|
|
}
|
|
|
|
string normalizedKeyId = keyId.Trim();
|
|
bool deleted = await adminStore
|
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await AppendAuditAsync(
|
|
normalizedKeyId,
|
|
"dashboard-delete-key",
|
|
deleted ? "deleted" : "not-found-or-active",
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return deleted
|
|
? DashboardApiKeyManagementResult.Success("API key deleted.")
|
|
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
|
}
|
|
|
|
private async Task AppendAuditAsync(
|
|
string? keyId,
|
|
string eventType,
|
|
string? details,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await auditStore.AppendAsync(
|
|
new ApiKeyAuditEntry(
|
|
KeyId: keyId,
|
|
EventType: eventType,
|
|
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
|
Details: details),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
|
{
|
|
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
|
if (keyIdValidation is not null)
|
|
{
|
|
return keyIdValidation;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
|
{
|
|
return "Display name is required.";
|
|
}
|
|
|
|
string[] unknownScopes = request.Scopes
|
|
.Where(scope => !GatewayScopes.IsKnown(scope))
|
|
.ToArray();
|
|
if (unknownScopes.Length > 0)
|
|
{
|
|
return $"Unknown scope(s): {string.Join(", ", unknownScopes)}. "
|
|
+ $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? ValidateKeyId(string keyId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keyId))
|
|
{
|
|
return "API key id is required.";
|
|
}
|
|
|
|
return keyId.Trim().All(character =>
|
|
char.IsAsciiLetterOrDigit(character)
|
|
|| character is '.' or '-')
|
|
? null
|
|
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
|
}
|
|
|
|
private static string FormatApiKey(string keyId, string secret)
|
|
{
|
|
return $"mxgw_{keyId}_{secret}";
|
|
}
|
|
}
|