Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented LdapDistinguishedNameHelper for escaping RDN and filter values. - Created AuthorityCredentialAuditContext and IAuthorityCredentialAuditContextAccessor for managing credential audit context. - Developed StandardCredentialAuditLogger with tests for success, failure, and lockout events. - Introduced AuthorityAuditSink for persisting audit records with structured logging. - Added CryptoPro related classes for certificate resolution and signing operations.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
|
||||
|
||||
public class StandardCredentialAuditLoggerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmitsSuccessEvent_WithContext()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var accessor = new TestCredentialAuditContextAccessor();
|
||||
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
|
||||
"corr-success",
|
||||
"client-app",
|
||||
"tenant-alpha",
|
||||
"203.0.113.10",
|
||||
"198.51.100.5",
|
||||
"TestAgent/1.0"));
|
||||
|
||||
var timestamp = DateTimeOffset.Parse("2025-11-08T12:00:00Z");
|
||||
var sut = new StandardCredentialAuditLogger(
|
||||
sink,
|
||||
accessor,
|
||||
new FixedTimeProvider(timestamp),
|
||||
NullLogger<StandardCredentialAuditLogger>.Instance);
|
||||
|
||||
await sut.RecordAsync(
|
||||
"standard",
|
||||
"alice",
|
||||
subjectId: "subject-1",
|
||||
success: true,
|
||||
failureCode: null,
|
||||
reason: null,
|
||||
properties: Array.Empty<AuthEventProperty>(),
|
||||
CancellationToken.None);
|
||||
|
||||
var record = Assert.Single(sink.Records);
|
||||
Assert.Equal("authority.plugin.standard.password_verification", record.EventType);
|
||||
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
|
||||
Assert.Equal(timestamp, record.OccurredAt);
|
||||
Assert.Equal("corr-success", record.CorrelationId);
|
||||
Assert.Equal("subject-1", record.Subject?.SubjectId.Value);
|
||||
Assert.Equal("alice", record.Subject?.Username.Value);
|
||||
Assert.Equal("client-app", record.Client?.ClientId.Value);
|
||||
Assert.Equal("standard", record.Client?.Provider.Value);
|
||||
Assert.Equal("tenant-alpha", record.Tenant.Value);
|
||||
Assert.Equal("203.0.113.10", record.Network?.RemoteAddress.Value);
|
||||
Assert.Equal("198.51.100.5", record.Network?.ForwardedFor.Value);
|
||||
Assert.Equal("TestAgent/1.0", record.Network?.UserAgent.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmitsFailureEvent_WithProperties()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var accessor = new TestCredentialAuditContextAccessor();
|
||||
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
|
||||
"corr-failure",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
var sut = new StandardCredentialAuditLogger(
|
||||
sink,
|
||||
accessor,
|
||||
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T13:00:00Z")),
|
||||
NullLogger<StandardCredentialAuditLogger>.Instance);
|
||||
|
||||
var properties = new[]
|
||||
{
|
||||
new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.failed_attempts",
|
||||
Value = ClassifiedString.Public("2")
|
||||
}
|
||||
};
|
||||
|
||||
await sut.RecordAsync(
|
||||
"standard",
|
||||
"bob",
|
||||
subjectId: null,
|
||||
success: false,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
reason: "Invalid credentials.",
|
||||
properties,
|
||||
CancellationToken.None);
|
||||
|
||||
var record = Assert.Single(sink.Records);
|
||||
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
|
||||
Assert.Equal("Invalid credentials.", record.Reason);
|
||||
Assert.Collection(
|
||||
record.Properties,
|
||||
property =>
|
||||
{
|
||||
Assert.Equal("plugin.failed_attempts", property.Name);
|
||||
Assert.Equal("2", property.Value.Value);
|
||||
},
|
||||
property =>
|
||||
{
|
||||
Assert.Equal("plugin.failure_code", property.Name);
|
||||
Assert.Equal(nameof(AuthorityCredentialFailureCode.InvalidCredentials), property.Value.Value);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmitsLockoutEvent_WithClassification()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var accessor = new TestCredentialAuditContextAccessor();
|
||||
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
|
||||
"corr-lockout",
|
||||
"client-app",
|
||||
"tenant-beta",
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
var sut = new StandardCredentialAuditLogger(
|
||||
sink,
|
||||
accessor,
|
||||
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T14:30:00Z")),
|
||||
NullLogger<StandardCredentialAuditLogger>.Instance);
|
||||
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "plugin.lockout_until",
|
||||
Value = ClassifiedString.Personal("2025-11-08T15:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
await sut.RecordAsync(
|
||||
"standard",
|
||||
"carol",
|
||||
subjectId: "subject-3",
|
||||
success: false,
|
||||
failureCode: AuthorityCredentialFailureCode.LockedOut,
|
||||
reason: "Account locked.",
|
||||
properties,
|
||||
CancellationToken.None);
|
||||
|
||||
var record = Assert.Single(sink.Records);
|
||||
Assert.Equal(AuthEventOutcome.LockedOut, record.Outcome);
|
||||
Assert.Equal("Account locked.", record.Reason);
|
||||
Assert.Equal("subject-3", record.Subject?.SubjectId.Value);
|
||||
Assert.Equal("tenant-beta", record.Tenant.Value);
|
||||
Assert.Collection(
|
||||
record.Properties,
|
||||
property =>
|
||||
{
|
||||
Assert.Equal("plugin.lockout_until", property.Name);
|
||||
Assert.Equal("2025-11-08T15:00:00Z", property.Value.Value);
|
||||
Assert.Equal(AuthEventDataClassification.Personal, property.Value.Classification);
|
||||
},
|
||||
property =>
|
||||
{
|
||||
Assert.Equal("plugin.failure_code", property.Name);
|
||||
Assert.Equal(nameof(AuthorityCredentialFailureCode.LockedOut), property.Value.Value);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_AddsFailureCode_WhenPropertiesNull()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var accessor = new TestCredentialAuditContextAccessor();
|
||||
|
||||
var sut = new StandardCredentialAuditLogger(
|
||||
sink,
|
||||
accessor,
|
||||
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T15:45:00Z")),
|
||||
NullLogger<StandardCredentialAuditLogger>.Instance);
|
||||
|
||||
await sut.RecordAsync(
|
||||
"standard",
|
||||
"dave",
|
||||
subjectId: "subject-4",
|
||||
success: false,
|
||||
failureCode: AuthorityCredentialFailureCode.RequiresMfa,
|
||||
reason: "MFA required.",
|
||||
properties: null,
|
||||
CancellationToken.None);
|
||||
|
||||
var record = Assert.Single(sink.Records);
|
||||
var property = Assert.Single(record.Properties);
|
||||
Assert.Equal("plugin.failure_code", property.Name);
|
||||
Assert.Equal(nameof(AuthorityCredentialFailureCode.RequiresMfa), property.Value.Value);
|
||||
}
|
||||
|
||||
private sealed class TestAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Records { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Records.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
|
||||
{
|
||||
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
|
||||
|
||||
public AuthorityCredentialAuditContext? Current => current.Value;
|
||||
|
||||
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
|
||||
{
|
||||
var previous = current.Value;
|
||||
current.Value = context;
|
||||
return new Scope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class Scope : IDisposable
|
||||
{
|
||||
private readonly TestCredentialAuditContextAccessor accessor;
|
||||
private readonly AuthorityCredentialAuditContext? previous;
|
||||
private bool disposed;
|
||||
|
||||
public Scope(TestCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
|
||||
{
|
||||
this.accessor = accessor;
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
accessor.current.Value = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset timestamp;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset timestamp)
|
||||
{
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => timestamp;
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,8 @@ public class StandardClientProvisioningStoreTests
|
||||
Assert.Contains("scopea", descriptor.AllowedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NormalisesTenant()
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NormalisesTenant()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
@@ -72,9 +71,8 @@ public class StandardClientProvisioningStoreTests
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("tenant-alpha", descriptor!.Tenant);
|
||||
}
|
||||
|
||||
|
||||
public async Task CreateOrUpdateAsync_StoresAudiences()
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_StoresAudiences()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
|
||||
@@ -13,9 +13,10 @@ using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
@@ -54,36 +55,8 @@ public class StandardPluginRegistrarTests
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
|
||||
{
|
||||
var mongo = sp.GetRequiredService<IMongoDatabase>();
|
||||
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
|
||||
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
|
||||
});
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
|
||||
{
|
||||
var mongo = sp.GetRequiredService<IMongoDatabase>();
|
||||
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
|
||||
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
|
||||
});
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -140,12 +113,10 @@ public class StandardPluginRegistrarTests
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -178,13 +149,8 @@ public class StandardPluginRegistrarTests
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -223,12 +189,9 @@ public class StandardPluginRegistrarTests
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
@@ -267,10 +230,8 @@ public class StandardPluginRegistrarTests
|
||||
configPath);
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -347,11 +308,11 @@ internal sealed class StubRevocationStore : IAuthorityRevocationStore
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
|
||||
internal sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
internal sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
@@ -363,6 +324,93 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly List<AuthorityLoginAttemptDocument> documents = new();
|
||||
|
||||
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(documents);
|
||||
}
|
||||
|
||||
internal sealed class TestAuthorityCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
|
||||
{
|
||||
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
|
||||
|
||||
public AuthorityCredentialAuditContext? Current => current.Value;
|
||||
|
||||
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
|
||||
{
|
||||
var previous = current.Value;
|
||||
current.Value = context;
|
||||
return new Scope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class Scope : IDisposable
|
||||
{
|
||||
private readonly TestAuthorityCredentialAuditContextAccessor accessor;
|
||||
private readonly AuthorityCredentialAuditContext? previous;
|
||||
private bool disposed;
|
||||
|
||||
public Scope(TestAuthorityCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
|
||||
{
|
||||
this.accessor = accessor;
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
accessor.current.Value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed class TestAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Records { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Records.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class StandardPluginRegistrarTestHelpers
|
||||
{
|
||||
public static ServiceCollection CreateServiceCollection(
|
||||
IMongoDatabase database,
|
||||
IAuthEventSink? authEventSink = null,
|
||||
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore>(new InMemoryLoginAttemptStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityCredentialAuditContextAccessor>(
|
||||
auditContextAccessor ?? new TestAuthorityCredentialAuditContextAccessor());
|
||||
services.AddSingleton<IAuthEventSink>(authEventSink ?? new TestAuthEventSink());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -10,6 +11,7 @@ using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
@@ -113,12 +115,27 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.NotNull(second.RetryAfter);
|
||||
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
|
||||
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
|
||||
var secondRetryProperty = second.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
|
||||
var expectedSecondRetry = Math.Ceiling(second.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||
Assert.Equal(expectedSecondRetry, secondRetryProperty.Value.Value);
|
||||
|
||||
Assert.Equal(2, auditLogger.Events.Count);
|
||||
var third = await store.VerifyPasswordAsync("bob", "nope", CancellationToken.None);
|
||||
Assert.False(third.Succeeded);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, third.FailureCode);
|
||||
Assert.NotNull(third.RetryAfter);
|
||||
var thirdRetryProperty = third.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
|
||||
var expectedThirdRetry = Math.Ceiling(third.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||
Assert.Equal(expectedThirdRetry, thirdRetryProperty.Value.Value);
|
||||
|
||||
Assert.Equal(3, auditLogger.Events.Count);
|
||||
Assert.False(auditLogger.Events[0].Success);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditLogger.Events[0].FailureCode);
|
||||
Assert.False(auditLogger.Events[1].Success);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, auditLogger.Events[1].FailureCode);
|
||||
var lastAudit = auditLogger.Events[^1];
|
||||
Assert.False(lastAudit.Success);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, lastAudit.FailureCode);
|
||||
Assert.Contains(lastAudit.Properties, property => property.Name == "plugin.retry_after_seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -206,12 +223,22 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
|
||||
bool success,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
string? reason,
|
||||
IReadOnlyList<AuthEventProperty> properties,
|
||||
IReadOnlyList<AuthEventProperty>? properties,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(new AuditEntry(normalizedUsername, success, failureCode, reason));
|
||||
events.Add(new AuditEntry(
|
||||
normalizedUsername,
|
||||
success,
|
||||
failureCode,
|
||||
reason,
|
||||
properties ?? Array.Empty<AuthEventProperty>()));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed record AuditEntry(string Username, bool Success, AuthorityCredentialFailureCode? FailureCode, string? Reason);
|
||||
internal sealed record AuditEntry(
|
||||
string Username,
|
||||
bool Success,
|
||||
AuthorityCredentialFailureCode? FailureCode,
|
||||
string? Reason,
|
||||
IReadOnlyList<AuthEventProperty> Properties);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user