Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
public sealed record ApiKeyOptions
|
||||
{
|
||||
public string TokenPrefix { get; init; } = "mxgw";
|
||||
public string PepperSecretName { get; init; } = "";
|
||||
public string SqlitePath { get; init; } = "";
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
|
||||
|
||||
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
|
||||
|
||||
public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure);
|
||||
|
||||
public interface IApiKeyVerifier
|
||||
{
|
||||
Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// As a positional record, <see cref="SecretHash"/> (<c>byte[]</c>) participates in equality
|
||||
/// BY REFERENCE. Two records whose <c>SecretHash</c> arrays contain identical bytes are NOT
|
||||
/// considered equal by <see cref="object.Equals(object?)"/>. Callers must not rely on value
|
||||
/// equality for <see cref="SecretHash"/>; use <see cref="System.MemoryExtensions.SequenceEqual{T}"/>
|
||||
/// or similar for content comparison.
|
||||
/// </remarks>
|
||||
public sealed record ApiKeyRecord(
|
||||
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
|
||||
IReadOnlySet<string> Scopes, string? ConstraintsJson,
|
||||
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
|
||||
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct);
|
||||
Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details);
|
||||
|
||||
/// <summary>
|
||||
/// Hash-free projection of an API-key record, safe to enumerate and surface to admins.
|
||||
/// Deliberately omits <c>SecretHash</c> so that listing keys can never leak secret material.
|
||||
/// </summary>
|
||||
public sealed record ApiKeyListItem(
|
||||
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes,
|
||||
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
|
||||
public interface IApiKeyAdminStore
|
||||
{
|
||||
Task CreateAsync(ApiKeyRecord record, CancellationToken ct);
|
||||
Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct);
|
||||
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
|
||||
/// The secret hash is never selected, so callers cannot use this to recover secret material.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IApiKeyAuditStore
|
||||
{
|
||||
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct);
|
||||
Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
|
||||
public sealed record LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string Server { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 3893;
|
||||
public LdapTransport Transport { get; init; } = LdapTransport.Ldaps;
|
||||
public bool AllowInsecure { get; init; }
|
||||
public string SearchBase { get; init; } = "";
|
||||
public string ServiceAccountDn { get; init; } = "";
|
||||
public string ServiceAccountPassword { get; init; } = "";
|
||||
public string UserNameAttribute { get; init; } = "cn";
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
}
|
||||
|
||||
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
|
||||
|
||||
public sealed record LdapAuthResult(bool Succeeded, string Username, string DisplayName, IReadOnlyList<string> Groups, LdapAuthFailure? Failure)
|
||||
{
|
||||
public static LdapAuthResult Success(string username, string displayName, IReadOnlyList<string> groups) => new(true, username, displayName, groups, null);
|
||||
public static LdapAuthResult Fail(LdapAuthFailure failure) => new(false, "", "", Array.Empty<string>(), failure);
|
||||
}
|
||||
|
||||
public interface ILdapAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates <paramref name="username"/> against the directory by bind-then-search and
|
||||
/// returns the outcome, including the resolved display name and group memberships on success.
|
||||
/// </summary>
|
||||
/// <param name="username">The login name to authenticate.</param>
|
||||
/// <param name="password">The credential to bind with.</param>
|
||||
/// <param name="ct">A token to request cancellation of the operation.</param>
|
||||
/// <returns>The authentication result.</returns>
|
||||
/// <remarks>
|
||||
/// The cancellation token is observed at entry only. Implementations backed by synchronous
|
||||
/// LDAP clients cannot abort an in-flight bind or search once it has been dispatched, so full
|
||||
/// cooperative cancellation is not guaranteed mid-call: a request that has already reached the
|
||||
/// directory will run to completion (subject to the configured connection timeout) even if the
|
||||
/// token is cancelled.
|
||||
/// </remarks>
|
||||
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
|
||||
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator }
|
||||
|
||||
public sealed record GroupRoleMapping<TRole>(IReadOnlyList<TRole> Roles, object? Scope);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a user's directory group memberships to a set of roles (typically
|
||||
/// <see cref="CanonicalRole"/>) plus an opaque scope payload.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRole">The role vocabulary, e.g. <see cref="CanonicalRole"/>.</typeparam>
|
||||
/// <remarks>
|
||||
/// This library ships only the contract. Concrete canonical→native mappers are provided
|
||||
/// per-consumer (config-backed for OtOpcUa/mxaccessgw, DB/delegate-backed for ScadaBridge),
|
||||
/// because the backing store and the canonical→native role/permission expansion stay per-project
|
||||
/// (see <c>scadaproj/components/auth/GAPS.md</c>, gaps C1/C2). No default implementation is shipped here.
|
||||
/// </remarks>
|
||||
public interface IGroupRoleMapper<TRole>
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps the supplied <paramref name="groups"/> to the roles and scope they grant.
|
||||
/// </summary>
|
||||
/// <param name="groups">The user's directory group memberships.</param>
|
||||
/// <param name="ct">A token to request cancellation of the operation.</param>
|
||||
/// <returns>The roles granted and an opaque scope payload.</returns>
|
||||
Task<GroupRoleMapping<TRole>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.Abstractions</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Auth contracts and canonical roles for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,206 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a verb that yields a freshly assembled token (create-key / rotate-key).
|
||||
/// The <see cref="Token"/> is the ONLY moment the secret is ever available; it is never
|
||||
/// retrievable afterwards. A <c>null</c> <see cref="Token"/> indicates the verb failed
|
||||
/// (for example, rotating a key that does not exist).
|
||||
/// </summary>
|
||||
public sealed record CreateKeyResult(string KeyId, string? Token);
|
||||
|
||||
/// <summary>Result of a mutating verb that succeeds or fails without yielding a token.</summary>
|
||||
public sealed record KeyActionResult(bool Succeeded, string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Reusable, front-end-agnostic API-key administration command set. Each verb returns a
|
||||
/// structured result and performs no console I/O, so consumers can wire their own CLI or HTTP
|
||||
/// front-end on top. Audit is wired here (the command layer): every mutating verb appends an
|
||||
/// <see cref="ApiKeyAuditEntry"/> via <see cref="IApiKeyAuditStore"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>create-key</c> and <c>rotate-key</c> return the assembled token EXACTLY ONCE — the only
|
||||
/// time the secret is ever available. No other result carries the secret or its hash;
|
||||
/// <see cref="ApiKeyListItem"/> is a hash-free projection by construction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyAdminCommands
|
||||
{
|
||||
private readonly ApiKeyOptions _options;
|
||||
private readonly IApiKeyAdminStore _adminStore;
|
||||
private readonly IApiKeyAuditStore _auditStore;
|
||||
private readonly IApiKeyPepperProvider _pepperProvider;
|
||||
private readonly SqliteAuthStoreMigrator _migrator;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
/// <summary>Creates the command set over the supplied stores and options.</summary>
|
||||
/// <param name="options">API-key options (token prefix, store path, ...).</param>
|
||||
/// <param name="adminStore">Mutating store (create / revoke / rotate / delete / list).</param>
|
||||
/// <param name="auditStore">Append-only audit store wired into every mutating verb.</param>
|
||||
/// <param name="pepperProvider">Resolves the pepper used to hash secrets.</param>
|
||||
/// <param name="migrator">Schema migrator used by <see cref="InitDbAsync"/>.</param>
|
||||
/// <param name="clock">Optional clock; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
public ApiKeyAdminCommands(
|
||||
ApiKeyOptions options,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IApiKeyAuditStore auditStore,
|
||||
IApiKeyPepperProvider pepperProvider,
|
||||
SqliteAuthStoreMigrator migrator,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(adminStore);
|
||||
ArgumentNullException.ThrowIfNull(auditStore);
|
||||
ArgumentNullException.ThrowIfNull(pepperProvider);
|
||||
ArgumentNullException.ThrowIfNull(migrator);
|
||||
|
||||
_options = options;
|
||||
_adminStore = adminStore;
|
||||
_auditStore = auditStore;
|
||||
_pepperProvider = pepperProvider;
|
||||
_migrator = migrator;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// init-db: applies the schema migration, then appends an <c>init-db</c> audit entry.
|
||||
/// </summary>
|
||||
public async Task InitDbAsync(string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
await _migrator.MigrateAsync(ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId: null, "init-db", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// create-key: generates a secret, persists its hash, appends a <c>create-key</c> audit entry,
|
||||
/// and returns the assembled token <c><prefix>_<keyId>_<secret></c> EXACTLY ONCE.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
if (keyId.Contains('_'))
|
||||
throw new ArgumentException("keyId must not contain '_'.", nameof(keyId));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
string pepper = RequirePepper();
|
||||
|
||||
string secret = ApiKeySecretGenerator.NewSecret();
|
||||
byte[] secretHash = ApiKeySecretHasher.Hash(secret, pepper);
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
|
||||
var record = new ApiKeyRecord(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
|
||||
SecretHash: secretHash,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes,
|
||||
ConstraintsJson: constraintsJson,
|
||||
CreatedUtc: now,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
|
||||
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
|
||||
return new CreateKeyResult(keyId, AssembleToken(keyId, secret));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// list-keys: returns the hash-free <see cref="ApiKeyListItem"/> projection of every key,
|
||||
/// newest first. This is a read, so it appends no audit entry and never carries secret material.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ApiKeyListItem>> ListKeysAsync(CancellationToken ct) =>
|
||||
_adminStore.ListAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// revoke-key: marks the key revoked and appends a <c>revoke-key</c> audit entry.
|
||||
/// All attempts are audited, including failures (key not found or already revoked) — this is
|
||||
/// intentional to maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> RevokeKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
bool revoked = await _adminStore.RevokeAsync(keyId, now, ct).ConfigureAwait(false);
|
||||
|
||||
string status = revoked ? "revoked" : "not-found-or-already-revoked";
|
||||
await AppendAuditAsync(keyId, "revoke-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(revoked, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// rotate-key: replaces the stored secret with a freshly generated one and appends a
|
||||
/// <c>rotate-key</c> audit entry. Returns a <see cref="CreateKeyResult"/> whose token is the new
|
||||
/// secret (shown once); a <c>null</c> token indicates the key did not exist.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
string pepper = RequirePepper();
|
||||
|
||||
string secret = ApiKeySecretGenerator.NewSecret();
|
||||
byte[] newHash = ApiKeySecretHasher.Hash(secret, pepper);
|
||||
|
||||
bool rotated = await _adminStore.RotateAsync(keyId, newHash, ct).ConfigureAwait(false);
|
||||
|
||||
string status = rotated ? "rotated" : "not-found";
|
||||
await AppendAuditAsync(keyId, "rotate-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new CreateKeyResult(keyId, rotated ? AssembleToken(keyId, secret) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// delete-key: removes the key (only succeeds once it has been revoked) and appends a
|
||||
/// <c>delete-key</c> audit entry.
|
||||
/// All attempts are audited, including failures (key not found or not yet revoked) — this is
|
||||
/// intentional to maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> DeleteKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
bool deleted = await _adminStore.DeleteAsync(keyId, ct).ConfigureAwait(false);
|
||||
|
||||
string status = deleted ? "deleted" : "not-found-or-not-revoked";
|
||||
await AppendAuditAsync(keyId, "delete-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(deleted, status);
|
||||
}
|
||||
|
||||
private string RequirePepper()
|
||||
{
|
||||
string? pepper = _pepperProvider.GetPepper();
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new InvalidOperationException("pepper unavailable");
|
||||
}
|
||||
|
||||
return pepper;
|
||||
}
|
||||
|
||||
private string AssembleToken(string keyId, string secret) =>
|
||||
$"{_options.TokenPrefix}_{keyId}_{secret}";
|
||||
|
||||
private Task AppendAuditAsync(
|
||||
string? keyId, string eventType, string? remoteAddress, string? details, CancellationToken ct) =>
|
||||
_auditStore.AppendAsync(
|
||||
new ApiKeyAuditEntry(keyId, eventType, remoteAddress, _clock.GetUtcNow(), details),
|
||||
ct);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal sealed record ParsedApiKey(string KeyId, string Secret);
|
||||
|
||||
internal static class ApiKeyParser
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an Authorization header value or a raw token into a <see cref="ParsedApiKey"/>.
|
||||
/// Accepts an optional case-insensitive "Bearer " scheme prefix before the token.
|
||||
/// Token format: <c><tokenPrefix>_<keyId>_<secret></c>.
|
||||
/// The secret may itself contain underscores; only the first underscore after the key-id is used as
|
||||
/// the key-id/secret separator.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeaderOrToken">Authorization header value or raw token string.</param>
|
||||
/// <param name="tokenPrefix">Expected token prefix (e.g. "mxgw"), without trailing underscore.</param>
|
||||
/// <returns>A <see cref="ParsedApiKey"/> on success, or <c>null</c> if the input is malformed.</returns>
|
||||
public static ParsedApiKey? TryParse(string? authorizationHeaderOrToken, string tokenPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeaderOrToken))
|
||||
return null;
|
||||
|
||||
string token = authorizationHeaderOrToken;
|
||||
|
||||
// Strip optional "Bearer " prefix (case-insensitive).
|
||||
if (token.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
token = token[BearerPrefix.Length..].Trim();
|
||||
|
||||
// Token must start with "<prefix>_"
|
||||
string requiredPrefix = tokenPrefix + "_";
|
||||
if (!token.StartsWith(requiredPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
// Everything after "<prefix>_" is "<keyId>_<secret>"
|
||||
string keyPayload = token[requiredPrefix.Length..];
|
||||
|
||||
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
|
||||
|
||||
// separatorIndex <= 0 means no underscore or empty keyId
|
||||
// separatorIndex == keyPayload.Length - 1 means empty secret
|
||||
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
|
||||
return null;
|
||||
|
||||
string keyId = keyPayload[..separatorIndex];
|
||||
string secret = keyPayload[(separatorIndex + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
|
||||
return null;
|
||||
|
||||
return new ParsedApiKey(keyId, secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal static class ApiKeySecretGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new cryptographically secure API key secret.
|
||||
/// Returns 32 random bytes encoded as URL-safe base64 (no padding, no '+', no '/').
|
||||
/// </summary>
|
||||
public static string NewSecret()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal static class ApiKeySecretHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)).
|
||||
/// </summary>
|
||||
public static byte[] Hash(string secret, string pepper)
|
||||
{
|
||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
using HMACSHA256 hmac = new(pepperBytes);
|
||||
return hmac.ComputeHash(secretBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true iff HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)) equals
|
||||
/// <paramref name="expectedHash"/>, using a constant-time comparison.
|
||||
/// Returns false (without throwing) if the lengths differ.
|
||||
/// </summary>
|
||||
public static bool Verify(string secret, string pepper, byte[] expectedHash)
|
||||
{
|
||||
byte[] actualHash = Hash(secret, pepper);
|
||||
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies presented API-key credentials against the key store, returning a structured,
|
||||
/// discriminated result. The pipeline is fail-closed: any inability to positively verify a
|
||||
/// credential yields a failure rather than a success.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The failure reason is discriminated for the caller/audit pipeline, but the verifier returns a
|
||||
/// structured result rather than throwing (the caller decides the opaque client-facing message).
|
||||
/// The only exception path is cancellation. A successful identity carries the key's scopes and the
|
||||
/// opaque <c>ConstraintsJson</c> blob (which the verifier does not interpret); it never carries the
|
||||
/// presented secret, the pepper, or the stored secret hash.
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyVerifier(
|
||||
ApiKeyOptions options,
|
||||
IApiKeyStore store,
|
||||
IApiKeyPepperProvider pepperProvider,
|
||||
TimeProvider? timeProvider = null) : IApiKeyVerifier
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. Parse the header/token. Malformed or wrong-prefix credentials are indistinguishable
|
||||
// from a missing credential and are reported uniformly.
|
||||
ParsedApiKey? parsed = ApiKeyParser.TryParse(authorizationHeader, options.TokenPrefix);
|
||||
if (parsed is null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.MissingOrMalformed);
|
||||
}
|
||||
|
||||
// 2. Resolve the pepper before touching the store. Without it, no verification is possible,
|
||||
// so we fail closed (and avoid an unnecessary store lookup).
|
||||
string? pepper = pepperProvider.GetPepper();
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
return Fail(ApiKeyFailure.PepperUnavailable);
|
||||
}
|
||||
|
||||
// 3. Look up the record (including revoked ones) so we can discriminate not-found vs revoked.
|
||||
ApiKeyRecord? record = await store.FindByKeyIdAsync(parsed.KeyId, ct).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyNotFound);
|
||||
}
|
||||
|
||||
// 4. Reject revoked keys.
|
||||
if (record.RevokedUtc is not null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
// 5. Constant-time secret comparison.
|
||||
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
|
||||
{
|
||||
return Fail(ApiKeyFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
|
||||
return new ApiKeyVerification(
|
||||
Succeeded: true,
|
||||
Identity: new ApiKeyIdentity(record.KeyId, record.DisplayName, record.Scopes, record.ConstraintsJson),
|
||||
Failure: null);
|
||||
}
|
||||
|
||||
private static ApiKeyVerification Fail(ApiKeyFailure failure) => new(false, null, failure);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the API-key SQLite schema migration at application startup when
|
||||
/// <see cref="ApiKeyOptions.RunMigrationsOnStartup"/> is <see langword="true"/>.
|
||||
/// The migration is idempotent, so repeated restarts are safe.
|
||||
/// </summary>
|
||||
internal sealed class ApiKeyMigrationHostedService(
|
||||
SqliteAuthStoreMigrator migrator,
|
||||
IOptions<ApiKeyOptions> options) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Value.RunMigrationsOnStartup)
|
||||
{
|
||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that wire up the ZB.MOM.WW API-key authentication provider
|
||||
/// from configuration. These compose the SQLite-backed stores and the configuration-backed
|
||||
/// pepper provider so a consuming app registers the verifier with a single call.
|
||||
/// </summary>
|
||||
public static class ApiKeyServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers API-key authentication: binds <see cref="ApiKeyOptions"/> from the
|
||||
/// configuration section at <paramref name="sectionPath"/>, wires up the SQLite-backed
|
||||
/// stores and the configuration-backed pepper provider, and registers
|
||||
/// <see cref="IApiKeyVerifier"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="sectionPath">Path of the configuration section holding the API-key options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbApiKeyAuth(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.Configure<ApiKeyOptions>(config.GetSection(sectionPath));
|
||||
|
||||
// The pepper provider reads the live IConfiguration on each call. In an ASP.NET Core
|
||||
// host IConfiguration is already registered; register the caller-supplied instance here
|
||||
// (TryAdd, so the host's own registration wins when present) so the provider resolves
|
||||
// even in a bare ServiceCollection.
|
||||
services.TryAddSingleton(config);
|
||||
services.TryAddSingleton<IApiKeyPepperProvider, ConfigurationApiKeyPepperProvider>();
|
||||
|
||||
// One connection factory targets the configured SQLite path. Singleton: it is
|
||||
// stateless aside from the path and opens a fresh connection per operation.
|
||||
services.TryAddSingleton(sp =>
|
||||
new AuthSqliteConnectionFactory(
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.SqlitePath));
|
||||
|
||||
services.TryAddSingleton<IApiKeyStore>(sp =>
|
||||
new SqliteApiKeyStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
services.TryAddSingleton<IApiKeyAdminStore>(sp =>
|
||||
new SqliteApiKeyAdminStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
services.TryAddSingleton<IApiKeyAuditStore>(sp =>
|
||||
new SqliteApiKeyAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
|
||||
services.TryAddSingleton<IApiKeyVerifier>(sp =>
|
||||
new ApiKeyVerifier(
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
|
||||
sp.GetRequiredService<IApiKeyStore>(),
|
||||
sp.GetRequiredService<IApiKeyPepperProvider>()));
|
||||
|
||||
// Migrator: singleton, constructed from the already-registered connection factory.
|
||||
// Needed before any store operations so the schema exists.
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqliteAuthStoreMigrator(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
|
||||
// Hosted service that runs migrations on startup when ApiKeyOptions.RunMigrationsOnStartup.
|
||||
services.AddHostedService<ApiKeyMigrationHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration-backed <see cref="IApiKeyPepperProvider"/> that resolves the API-key pepper
|
||||
/// from <see cref="IConfiguration"/> using the key name in
|
||||
/// <see cref="ApiKeyOptions.PepperSecretName"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pepper is read live from configuration on each call so that a secret rotated in the
|
||||
/// underlying provider (e.g. an environment variable or a refreshed secret store) takes effect
|
||||
/// without restarting the process. When the secret name is unconfigured or the value is absent,
|
||||
/// <see cref="GetPepper"/> returns <see langword="null"/>/empty, which the verifier treats as a
|
||||
/// fail-closed "pepper unavailable" condition.
|
||||
/// </remarks>
|
||||
public sealed class ConfigurationApiKeyPepperProvider(
|
||||
IConfiguration config,
|
||||
IOptions<ApiKeyOptions> options) : IApiKeyPepperProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? GetPepper()
|
||||
{
|
||||
string secretName = options.Value.PepperSecretName;
|
||||
|
||||
return string.IsNullOrWhiteSpace(secretName)
|
||||
? null
|
||||
: config[secretName];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the secret pepper used to verify API-key secret hashes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations resolve the pepper from a configured secret source. A concrete,
|
||||
/// configuration-backed provider is wired up separately; this abstraction lets the
|
||||
/// verifier fail closed when the pepper cannot be resolved.
|
||||
/// </remarks>
|
||||
public interface IApiKeyPepperProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the resolved pepper, or <c>null</c>/empty if it is currently unavailable.
|
||||
/// </summary>
|
||||
/// <returns>The pepper value, or <c>null</c>/whitespace when unavailable.</returns>
|
||||
string? GetPepper();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating and opening SQLite connections to the API-key store.
|
||||
/// </summary>
|
||||
public sealed class AuthSqliteConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Busy timeout applied to every connection. SQLite retries a busy database for
|
||||
/// this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
|
||||
/// mark-used / audit-append writers degrade gracefully under load instead of
|
||||
/// failing the request path.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _sqlitePath;
|
||||
|
||||
/// <summary>Creates a factory targeting the database at <paramref name="sqlitePath"/>.</summary>
|
||||
/// <param name="sqlitePath">Filesystem path of the SQLite database file.</param>
|
||||
public AuthSqliteConnectionFactory(string sqlitePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
|
||||
_sqlitePath = sqlitePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
|
||||
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
|
||||
/// busy timeout.
|
||||
/// </summary>
|
||||
public SqliteConnection CreateConnection()
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_sqlitePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = _sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = true,
|
||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
|
||||
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
|
||||
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>An opened and configured SQLite connection.</returns>
|
||||
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
SqliteConnection connection = CreateConnection();
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ConfigureConnectionAsync(
|
||||
SqliteConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// WAL is a persistent, database-level setting; re-applying it per connection
|
||||
// is cheap and a no-op once set. busy_timeout is per-connection state.
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes API-key scope sets to a canonical JSON array. Scopes are sorted with
|
||||
/// <see cref="StringComparer.Ordinal"/> so that equal sets always produce identical
|
||||
/// column text, regardless of insertion order.
|
||||
/// </summary>
|
||||
public static class ScopeSerializer
|
||||
{
|
||||
/// <summary>Serializes scopes to an ordinal-sorted JSON array.</summary>
|
||||
/// <param name="scopes">The scopes to serialize.</param>
|
||||
/// <returns>A JSON array string with elements sorted ordinally.</returns>
|
||||
public static string Serialize(IReadOnlySet<string> scopes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Deserializes scopes from a JSON array string.</summary>
|
||||
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
|
||||
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
|
||||
public static IReadOnlySet<string> Deserialize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
|
||||
/// </summary>
|
||||
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", record.KeyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
|
||||
command.Parameters.AddWithValue("$display_name", record.DisplayName);
|
||||
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
|
||||
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
|
||||
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = $revoked_utc
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(newSecretHash);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET secret_hash = $secret_hash,
|
||||
last_used_utc = NULL,
|
||||
revoked_utc = NULL
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = newSecretHash;
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM api_keys
|
||||
WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
|
||||
// Deliberately omits secret_hash so listing can never leak secret material.
|
||||
command.CommandText = """
|
||||
SELECT key_id, key_prefix, display_name, scopes, constraints,
|
||||
created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
ORDER BY created_utc DESC, key_id DESC;
|
||||
""";
|
||||
|
||||
List<ApiKeyListItem> items = [];
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new ApiKeyListItem(
|
||||
KeyId: reader.GetString(0),
|
||||
KeyPrefix: reader.GetString(1),
|
||||
DisplayName: reader.GetString(2),
|
||||
Scopes: ScopeSerializer.Deserialize(reader.GetString(3)),
|
||||
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>SQLite-backed, append-only audit store for API-key events.</summary>
|
||||
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
|
||||
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$event_type", entry.EventType);
|
||||
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", entry.CreatedUtc.ToString("O"));
|
||||
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT key_id, event_type, remote_address, created_utc, details
|
||||
FROM api_key_audit
|
||||
ORDER BY audit_id DESC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
List<ApiKeyAuditEntry> entries = [];
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add(new ApiKeyAuditEntry(
|
||||
KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
|
||||
EventType: reader.GetString(1),
|
||||
RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(3)),
|
||||
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>SQLite-backed read store for API-key records.</summary>
|
||||
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
return FindAsync(keyId, requireActive: false, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
return FindAsync(keyId, requireActive: true, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET last_used_utc = $last_used_utc
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$last_used_utc", whenUtc.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ApiKeyRecord?> FindAsync(string keyId, bool requireActive, CancellationToken ct)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
? $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id AND revoked_utc IS NULL;"
|
||||
: $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id;";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadRecord(reader);
|
||||
}
|
||||
|
||||
internal static ApiKeyRecord ReadRecord(SqliteDataReader reader) => new(
|
||||
KeyId: reader.GetString(0),
|
||||
KeyPrefix: reader.GetString(1),
|
||||
SecretHash: reader.GetFieldValue<byte[]>(2),
|
||||
DisplayName: reader.GetString(3),
|
||||
Scopes: ScopeSerializer.Deserialize(reader.GetString(4)),
|
||||
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Schema constants and table DDL for the API-key SQLite store.
|
||||
/// </summary>
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
/// <summary>The schema version this build creates and supports.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
/// <summary>Name of the table storing API-key records.</summary>
|
||||
public const string ApiKeysTable = "api_keys";
|
||||
|
||||
/// <summary>Name of the append-only audit table.</summary>
|
||||
public const string ApiKeyAuditTable = "api_key_audit";
|
||||
|
||||
/// <summary>DDL creating the single-row schema-version table.</summary>
|
||||
public const string CreateSchemaVersionTable = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL,
|
||||
applied_utc TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating the API-key record table.</summary>
|
||||
public const string CreateApiKeysTable = """
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
key_prefix TEXT NOT NULL,
|
||||
secret_hash BLOB NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
constraints TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating the append-only audit table.</summary>
|
||||
public const string CreateApiKeyAuditTable = """
|
||||
CREATE TABLE IF NOT EXISTS api_key_audit (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id TEXT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
remote_address TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
details TEXT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating supporting indexes (idempotent).</summary>
|
||||
public const string CreateIndexes = """
|
||||
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
|
||||
ON api_keys (revoked_utc);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
|
||||
ON api_key_audit (key_id, created_utc);
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>Thrown when the auth store cannot be migrated to the supported schema.</summary>
|
||||
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the API-key store schema and records the applied version. Idempotent: it
|
||||
/// is safe to run repeatedly. Refuses to run against a database whose on-disk version
|
||||
/// is newer than this build supports.
|
||||
/// </summary>
|
||||
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory)
|
||||
{
|
||||
/// <summary>Applies the schema migration to the auth store.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="AuthStoreMigrationException">
|
||||
/// The on-disk schema version is newer than <see cref="SqliteAuthSchema.CurrentVersion"/>.
|
||||
/// </exception>
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteTransaction transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int existingVersion =
|
||||
await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
||||
{
|
||||
throw new AuthStoreMigrationException(
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ReadExistingSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
|
||||
tableExistsCommand.Transaction = transaction;
|
||||
tableExistsCommand.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = $table_name;
|
||||
""";
|
||||
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
|
||||
|
||||
long tableCount =
|
||||
(long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
|
||||
|
||||
if (tableCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
SELECT version
|
||||
FROM schema_version
|
||||
WHERE id = 1;
|
||||
""";
|
||||
|
||||
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return version is null || version == DBNull.Value
|
||||
? 0
|
||||
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionOneAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
string.Join(
|
||||
"\n",
|
||||
SqliteAuthSchema.CreateSchemaVersionTable,
|
||||
SqliteAuthSchema.CreateApiKeysTable,
|
||||
SqliteAuthSchema.CreateApiKeyAuditTable,
|
||||
SqliteAuthSchema.CreateIndexes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
INSERT INTO schema_version (id, version, applied_utc)
|
||||
VALUES (1, $version, $applied_utc)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
applied_utc = excluded.applied_utc;
|
||||
""";
|
||||
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
|
||||
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string commandText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = commandText;
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for reading round-trippable timestamps out of the SQLite stores.
|
||||
/// All timestamps are persisted with the round-trip ("O") format, so parsing is centralized
|
||||
/// here to keep the three stores DRY and consistent.
|
||||
/// </summary>
|
||||
internal static class SqliteValueParsing
|
||||
{
|
||||
/// <summary>Parses a round-trip ("O") formatted timestamp written by the stores.</summary>
|
||||
internal static DateTimeOffset ParseUtc(string value) =>
|
||||
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
/// <summary>Reads a nullable round-trip timestamp at <paramref name="ordinal"/>.</summary>
|
||||
internal static DateTimeOffset? ReadNullableUtc(SqliteDataReader reader, int ordinal) =>
|
||||
reader.IsDBNull(ordinal) ? null : ParseUtc(reader.GetString(ordinal));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<!--
|
||||
Lightweight Microsoft.Extensions.* abstractions back the DI helpers (AddZbApiKeyAuth):
|
||||
IServiceCollection / TryAdd* / Configure (DependencyInjection.Abstractions), IOptions
|
||||
(Options), IConfiguration (Configuration.Abstractions), and IHostedService / AddHostedService
|
||||
(Hosting.Abstractions). These are plain libraries — no FrameworkReference — so an LDAP-only
|
||||
consumer still pays nothing for ApiKeys, while an API-key consumer wires up with one call.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<!-- Supplies the Configure<TOptions>(IConfiguration) binding overload used by AddZbApiKeyAuth. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.ApiKeys</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>SQLite-backed API-key store with pepper-based hashing for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that wire up the ZB.MOM.WW LDAP authentication provider
|
||||
/// from configuration. Composes the concrete implementation living in the
|
||||
/// <c>ZB.MOM.WW.Auth.Ldap</c> package so consuming apps register a provider with a single call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// API-key DI wiring lives in <c>ZB.MOM.WW.Auth.ApiKeys</c>
|
||||
/// (<c>ZB.MOM.WW.Auth.ApiKeys.DependencyInjection.ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth</c>)
|
||||
/// so that an LDAP-only consumer can reference this package without pulling in SQLite.
|
||||
/// </remarks>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers LDAP authentication: binds and validates <see cref="LdapOptions"/> from the
|
||||
/// configuration section at <paramref name="sectionPath"/>, and registers
|
||||
/// <see cref="ILdapAuthService"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="sectionPath">Path of the configuration section holding the LDAP options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbLdapAuth(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.Configure<LdapOptions>(config.GetSection(sectionPath));
|
||||
|
||||
// Fail fast at startup on a misconfigured directory rather than on first login.
|
||||
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
|
||||
|
||||
// LdapAuthService is stateless: it holds only a snapshot of LdapOptions and a stateless
|
||||
// connection factory, and opens/disposes a connection per call. It is not IDisposable.
|
||||
// Singleton is correct; TryAdd mirrors the pattern in AddZbApiKeyAuth (idempotency).
|
||||
services.TryAddSingleton<ILdapAuthService>(sp =>
|
||||
new LdapAuthService(sp.GetRequiredService<IOptions<LdapOptions>>().Value));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.AspNetCore</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>ASP.NET Core DI helpers, cookie defaults, and claim mappings for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Microsoft.AspNetCore.App is a shared framework, not a NuGet package. It brings in
|
||||
cookie authentication (Microsoft.AspNetCore.Authentication.Cookies), authorization,
|
||||
and the Microsoft.Extensions.* surface (Configuration.Abstractions, Options,
|
||||
DependencyInjection.Abstractions) used by the DI helpers below. There is no net10
|
||||
standalone NuGet package for cookie auth, so referencing the shared framework is the
|
||||
supported path.
|
||||
-->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical claim-type constants used across ZB.MOM.WW authentication. Centralising the
|
||||
/// strings here keeps claim issuance (LDAP/API-key sign-in) and claim consumption
|
||||
/// (authorization policies, role checks) in agreement on exactly one spelling per concept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Name"/> and <see cref="Role"/> deliberately alias the framework's
|
||||
/// <see cref="ClaimTypes.Name"/> and <see cref="ClaimTypes.Role"/> URIs so that ASP.NET
|
||||
/// Core's built-in <see cref="ClaimsPrincipal.Identity"/> name resolution and
|
||||
/// <c>[Authorize(Roles = ...)]</c> / <see cref="ClaimsPrincipal.IsInRole(string)"/> checks
|
||||
/// work without bespoke configuration. The remaining claim types are app-specific and use
|
||||
/// stable, short <c>zb:</c>-prefixed names that will not collide with the framework URIs.
|
||||
/// </remarks>
|
||||
public static class ZbClaimTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// The principal's name claim. Aliases <see cref="ClaimTypes.Name"/> so the framework
|
||||
/// populates <see cref="System.Security.Principal.IIdentity.Name"/> from it.
|
||||
/// </summary>
|
||||
public const string Name = ClaimTypes.Name;
|
||||
|
||||
/// <summary>
|
||||
/// A role claim. Aliases <see cref="ClaimTypes.Role"/> so <c>[Authorize(Roles = ...)]</c>
|
||||
/// and <see cref="ClaimsPrincipal.IsInRole(string)"/> resolve against it by default.
|
||||
/// </summary>
|
||||
public const string Role = ClaimTypes.Role;
|
||||
|
||||
/// <summary>Human-friendly display name (distinct from the login <see cref="Name"/>).</summary>
|
||||
public const string DisplayName = "zb:displayname";
|
||||
|
||||
/// <summary>The directory/login username the principal authenticated as.</summary>
|
||||
public const string Username = "zb:username";
|
||||
|
||||
/// <summary>The identifier of the scope (site/area) the principal's roles apply within.</summary>
|
||||
public const string ScopeId = "zb:scopeid";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the hardened cookie-authentication defaults shared by ZB.MOM.WW apps:
|
||||
/// HTTP-only, <see cref="SameSiteMode.Strict"/>, sliding expiration, a caller-supplied idle
|
||||
/// timeout, and a configurable HTTPS requirement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cookie <em>name</em> is intentionally left untouched: each app owns its own cookie name
|
||||
/// (so two apps on the same host do not clobber each other's session), and the caller sets it
|
||||
/// when configuring the cookie scheme.
|
||||
/// </remarks>
|
||||
public static class ZbCookieDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Default idle timeout used when a caller does not supply one. After this much inactivity
|
||||
/// the (sliding) session cookie expires and the principal must re-authenticate.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the hardened defaults to <paramref name="options"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The cookie-authentication options to mutate.</param>
|
||||
/// <param name="requireHttps">
|
||||
/// When <see langword="true"/> (the default), the cookie is only ever sent over HTTPS
|
||||
/// (<see cref="CookieSecurePolicy.Always"/>). Set to <see langword="false"/> only for local
|
||||
/// development over plain HTTP (<see cref="CookieSecurePolicy.SameAsRequest"/>: Secure is
|
||||
/// still set when the current request is HTTPS, which is safer than <c>None</c>).
|
||||
/// </param>
|
||||
/// <param name="idleTimeout">
|
||||
/// The sliding idle timeout. Defaults to <see cref="DefaultIdleTimeout"/> when not specified.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
public static void Apply(
|
||||
CookieAuthenticationOptions options,
|
||||
bool requireHttps = true,
|
||||
TimeSpan? idleTimeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = requireHttps
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = idleTimeout ?? DefaultIdleTimeout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// A single LDAP search result entry: its DN and a flat attribute bag.
|
||||
/// </summary>
|
||||
internal sealed record LdapSearchEntry(
|
||||
string Dn,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over a single LDAP connection. Allows unit-testing
|
||||
/// <c>LdapAuthService</c> without a live directory server.
|
||||
/// </summary>
|
||||
internal interface ILdapConnection : IDisposable
|
||||
{
|
||||
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
|
||||
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
|
||||
|
||||
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
|
||||
void Bind(string dn, string password);
|
||||
|
||||
/// <summary>Executes a subtree search and returns all matching entries.</summary>
|
||||
IReadOnlyList<LdapSearchEntry> Search(string searchBase, string filter, IReadOnlyList<string> attributes);
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces <see cref="ILdapConnection"/> instances.</summary>
|
||||
internal interface ILdapConnectionFactory
|
||||
{
|
||||
ILdapConnection Create();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4515 LDAP filter escaping and RFC 4514 DN attribute-value escaping utilities.
|
||||
/// </summary>
|
||||
internal static class LdapEscaping
|
||||
{
|
||||
/// <summary>
|
||||
/// Escapes a string for safe use inside an RFC 4515 LDAP search filter assertion value.
|
||||
/// Escapes (in order): backslash, asterisk, left-paren, right-paren, NUL.
|
||||
/// </summary>
|
||||
public static string Filter(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Backslash must be escaped first so we don't double-escape subsequent replacements.
|
||||
return input
|
||||
.Replace("\\", @"\5c")
|
||||
.Replace("*", @"\2a")
|
||||
.Replace("(", @"\28")
|
||||
.Replace(")", @"\29")
|
||||
.Replace("\0", @"\00");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a string for safe use as an RFC 4514 DN attribute value.
|
||||
/// Escapes: , + " \ < > ; (with a leading backslash);
|
||||
/// also escapes a leading '#' and leading/trailing space.
|
||||
/// </summary>
|
||||
public static string Dn(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
var sb = new System.Text.StringBuilder(input.Length + 8);
|
||||
for (var i = 0; i < input.Length; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
switch (c)
|
||||
{
|
||||
case ',':
|
||||
case '+':
|
||||
case '"':
|
||||
case '\\':
|
||||
case '<':
|
||||
case '>':
|
||||
case ';':
|
||||
sb.Append('\\').Append(c);
|
||||
break;
|
||||
case '#' when i == 0:
|
||||
sb.Append(@"\#");
|
||||
break;
|
||||
case ' ' when i == 0 || i == input.Length - 1:
|
||||
sb.Append(@"\ ");
|
||||
break;
|
||||
default:
|
||||
sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the value of the first RDN from a DN, e.g.
|
||||
/// <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>. The scan is RFC 4514 escape-aware:
|
||||
/// a backslash-escaped <c>,</c> inside the RDN value does not terminate it, and recognised
|
||||
/// escape sequences — single-character (<c>\,</c> <c>\\</c> …) and two-digit hex
|
||||
/// (<c>\2c</c>) — are unescaped, so a group CN that legitimately contains a comma is
|
||||
/// returned intact (Security-013). A string with no <c>=</c> is returned unchanged.
|
||||
/// </summary>
|
||||
public static string FirstRdnValue(string dn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dn))
|
||||
return dn;
|
||||
|
||||
var equalsIndex = dn.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
return dn;
|
||||
|
||||
var valueStart = equalsIndex + 1;
|
||||
var sb = new System.Text.StringBuilder(dn.Length - valueStart);
|
||||
|
||||
for (var i = valueStart; i < dn.Length; i++)
|
||||
{
|
||||
var c = dn[i];
|
||||
if (c == '\\' && i + 1 < dn.Length)
|
||||
{
|
||||
var next = dn[i + 1];
|
||||
// RFC 4514 hex escape: \XX (two hex digits).
|
||||
if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
|
||||
{
|
||||
sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
|
||||
i += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
|
||||
// escaped character literally and skip the backslash.
|
||||
sb.Append(next);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ',')
|
||||
{
|
||||
// Unescaped comma terminates the first RDN.
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsHexDigit(char c)
|
||||
=> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
|
||||
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
|
||||
/// </summary>
|
||||
internal sealed class NovellLdapConnection : ILdapConnection
|
||||
{
|
||||
private readonly LdapConnection _conn = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
{
|
||||
ApplyTimeout(timeoutMs);
|
||||
|
||||
// LDAPS: TLS is negotiated at the TCP-connection level.
|
||||
if (transport == LdapTransport.Ldaps)
|
||||
_conn.SecureSocketLayer = true;
|
||||
|
||||
_conn.Connect(host, port);
|
||||
|
||||
// StartTLS: connect plaintext first, then upgrade inside the session.
|
||||
if (transport == LdapTransport.StartTls)
|
||||
{
|
||||
_conn.StartTls();
|
||||
|
||||
if (!_conn.Tls)
|
||||
throw new InvalidOperationException(
|
||||
"StartTLS upgrade did not produce an encrypted session.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Bind(string dn, string password)
|
||||
=> _conn.Bind(dn, password);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<LdapSearchEntry> Search(
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
{
|
||||
var results = _conn.Search(
|
||||
searchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attributes.ToArray(),
|
||||
typesOnly: false);
|
||||
|
||||
var entries = new List<LdapSearchEntry>();
|
||||
while (results.HasMore())
|
||||
{
|
||||
var entry = results.Next();
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
||||
{
|
||||
attrs[attr.Name] = attr.StringValueArray.ToList();
|
||||
}
|
||||
|
||||
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_conn.Connected)
|
||||
_conn.Disconnect();
|
||||
_conn.Dispose();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void ApplyTimeout(int timeoutMs)
|
||||
{
|
||||
if (timeoutMs <= 0)
|
||||
return;
|
||||
|
||||
_conn.ConnectionTimeout = timeoutMs;
|
||||
|
||||
// SearchConstraints.TimeLimit is per-operation (ms). SearchConstraints getter
|
||||
// returns the live LdapSearchConstraints object (read-only property), but
|
||||
// TimeLimit is mutable in-place via the base LdapConstraints type.
|
||||
// We then assign it back through the writable Constraints property so
|
||||
// Novell picks up the change — mirrors ScadaBridge idiom.
|
||||
var constraints = _conn.SearchConstraints;
|
||||
constraints.TimeLimit = timeoutMs;
|
||||
_conn.Constraints = constraints;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
|
||||
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
|
||||
{
|
||||
public ILdapConnection Create() => new NovellLdapConnection();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
|
||||
/// bind as the service account, search for the user entry, then re-bind as the user
|
||||
/// to verify their password. Connection mechanics are delegated to an
|
||||
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fully fail-closed: authentication never throws to the caller — every error, expected
|
||||
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
|
||||
/// A success is only returned for a user that resolved to exactly one entry, whose password
|
||||
/// verified, AND who has at least one group (zero groups is never admitted as success).
|
||||
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
|
||||
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
|
||||
/// a system misconfiguration is not mistaken for bad user input.
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly ILdapConnectionFactory _connectionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: binds against a live directory via the real
|
||||
/// Novell-backed connection factory.
|
||||
/// </summary>
|
||||
public LdapAuthService(LdapOptions options)
|
||||
: this(options, new NovellLdapConnectionFactory())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/seam constructor: accepts an injected <see cref="ILdapConnectionFactory"/>
|
||||
/// so the bind/search logic can be exercised without a live directory. Internal
|
||||
/// because the connection seam is an implementation detail.
|
||||
/// </summary>
|
||||
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
|
||||
{
|
||||
_options = options;
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Fail-closed contract: this method never throws to the caller. Every stage of the
|
||||
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
|
||||
/// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
|
||||
/// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
|
||||
/// one entry, their password verified, AND at least one group was extracted.
|
||||
/// </remarks>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
// The Novell calls behind ILdapConnection are synchronous and blocking, so the token
|
||||
// cannot interrupt an in-progress operation; it is only observed here at entry, before
|
||||
// any work begins.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Authenticate(username, password));
|
||||
}
|
||||
|
||||
private LdapAuthResult Authenticate(string username, string password)
|
||||
{
|
||||
// 1. Feature gate: an explicitly disabled provider must never touch the network.
|
||||
if (!_options.Enabled)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
|
||||
|
||||
// 2. Reject a missing username before anything else — guarding here means a null
|
||||
// username can't NRE into the catch-all and surface as a system-side failure.
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
|
||||
// 3. Normalise once, up front, so the same canonical value flows into the LDAP
|
||||
// filter and the returned result (avoids two identities for one person).
|
||||
username = username.Trim();
|
||||
|
||||
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
|
||||
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
|
||||
// the most conservative system-side bucket. Either way the caller gets a structured result.
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.Create();
|
||||
|
||||
// 4. Open the connection (transport/TLS handling lives in the adapter). The
|
||||
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
|
||||
// A failure to connect/upgrade means the directory is unreachable — a
|
||||
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
|
||||
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
|
||||
// ServiceAccountBindFailed is the closest system-side bucket. A future
|
||||
// Abstractions change could add DirectoryUnavailable to disambiguate.
|
||||
try
|
||||
{
|
||||
conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
|
||||
// 5. Service-account bind so we can search for the user's DN. A bind failure
|
||||
// here is a service-account misconfiguration — DISTINCT from a user-credential
|
||||
// failure — so it maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
|
||||
// 6. Search for the user entry by the configured username attribute. A search
|
||||
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
|
||||
// closest system-side bucket until a DirectoryUnavailable value exists.
|
||||
IReadOnlyList<LdapSearchEntry> entries;
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
|
||||
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
if (entries.Count >= 2)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success.
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
// 10. Success — and only here, with a verified password and >= 1 group.
|
||||
var displayName = ExtractDisplayName(entry, username);
|
||||
return LdapAuthResult.Success(username, displayName, groups);
|
||||
}
|
||||
catch (StageFailure stage)
|
||||
{
|
||||
// A stage mapped its own failure; surface it as a structured result.
|
||||
return LdapAuthResult.Fail(stage.Failure);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
|
||||
// system-side bucket rather than propagating to the caller.
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
|
||||
/// out of a stage to the single fail-closed catch site. Never escapes this type.
|
||||
/// </summary>
|
||||
private sealed class StageFailure : Exception
|
||||
{
|
||||
public StageFailure(LdapAuthFailure failure) => Failure = failure;
|
||||
|
||||
public LdapAuthFailure Failure { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the distinct attribute list requested from the directory. The display-name
|
||||
/// and group attributes are de-duplicated so we never request the same attribute twice
|
||||
/// when an operator configures them to the same value.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildSearchAttributes()
|
||||
{
|
||||
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
return new[] { _options.DisplayNameAttribute };
|
||||
|
||||
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first value of the configured display-name attribute, falling back to
|
||||
/// the (already normalised) username when the directory entry has no such attribute.
|
||||
/// </summary>
|
||||
private string ExtractDisplayName(LdapSearchEntry entry, string username)
|
||||
{
|
||||
if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
|
||||
return values[0];
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts group short names from the configured group attribute. Each value is a
|
||||
/// group DN (e.g. <c>cn=Engineers,ou=g,dc=x</c>); the first RDN's value is returned
|
||||
/// (e.g. <c>Engineers</c>), RFC 4514 escape-aware so an escaped comma in the CN is
|
||||
/// preserved rather than truncating the name.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> ExtractGroups(LdapSearchEntry entry)
|
||||
{
|
||||
if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var groups = new List<string>(values.Count);
|
||||
foreach (var value in values)
|
||||
groups.Add(ToGroupShortName(value));
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields a group's short name from its DN by returning the value of the first RDN
|
||||
/// (e.g. <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>). The extraction is RFC 4514
|
||||
/// escape-aware (<see cref="LdapEscaping.FirstRdnValue"/>), so a CN that legitimately
|
||||
/// contains an escaped comma — <c>cn=Eng\,ineers,...</c> or <c>cn=A\2cB,...</c> — is
|
||||
/// returned intact rather than truncated at the escaped comma. Values with no <c>=</c>
|
||||
/// are returned unchanged.
|
||||
/// </summary>
|
||||
private static string ToGroupShortName(string groupDn)
|
||||
=> LdapEscaping.FirstRdnValue(groupDn);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="LdapOptions"/> at startup so a misconfiguration fails fast at
|
||||
/// boot with a clear, field-naming message — rather than surfacing later as an opaque
|
||||
/// low-level error on the first real login attempt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four conditions are enforced:
|
||||
/// <list type="bullet">
|
||||
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
|
||||
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, LdapOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.Transport)} is {nameof(LdapTransport.None)} (insecure/plaintext) " +
|
||||
$"but {nameof(LdapOptions.AllowInsecure)} is false. Enable TLS " +
|
||||
$"({nameof(LdapTransport.Ldaps)} or {nameof(LdapTransport.StartTls)}) " +
|
||||
$"or set {nameof(LdapOptions.AllowInsecure)} for dev/test.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Server))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.Server)} is required but was empty or whitespace — " +
|
||||
"set it to the LDAP server hostname or IP (e.g. \"ldap.example.com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.SearchBase))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.SearchBase)} is required but was empty or whitespace — " +
|
||||
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
|
||||
"an empty value would bind anonymously. Set it to the service-account DN " +
|
||||
"(e.g. \"cn=svc,dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.Ldap</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>LDAP authentication service (GLAuth / Active Directory) for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.Ldap.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user