Tests fixes, audit progress, UI completions
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Authority Standard Plugin Tests AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: Standard plugin test coverage, credential flows, and determinism.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/modules/authority/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Keep tests deterministic (fixed time/IDs); avoid external network calls.
|
||||
- Prefer exercising production code paths over test-only simulations.
|
||||
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
|
||||
|
||||
## Testing
|
||||
- Use xUnit + FluentAssertions + TestKit helpers.
|
||||
- Cover credential flows, lockouts, bootstrap behavior, and client provisioning.
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
@@ -16,16 +17,16 @@ using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Persistence.Documents;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardPluginRegistrarTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
@@ -58,10 +59,11 @@ public class StandardPluginRegistrarTests
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hostedServices = provider.GetServices<IHostedService>();
|
||||
@@ -88,7 +90,7 @@ public class StandardPluginRegistrarTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
@@ -116,12 +118,13 @@ public class StandardPluginRegistrarTests
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
@@ -134,7 +137,7 @@ public class StandardPluginRegistrarTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Register_ForcesPasswordCapability_WhenManifestMissing()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
@@ -152,10 +155,11 @@ public class StandardPluginRegistrarTests
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
@@ -167,7 +171,7 @@ public class StandardPluginRegistrarTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
@@ -191,10 +195,11 @@ public class StandardPluginRegistrarTests
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
@@ -202,7 +207,7 @@ public class StandardPluginRegistrarTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Register_NormalizesTokenSigningKeyDirectory()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
@@ -232,11 +237,12 @@ public class StandardPluginRegistrarTests
|
||||
configPath);
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
@@ -398,6 +404,7 @@ internal static class StandardPluginRegistrarTestHelpers
|
||||
{
|
||||
public static ServiceCollection CreateServiceCollection(
|
||||
IDatabase database,
|
||||
IConfiguration? configuration = null,
|
||||
IAuthEventSink? authEventSink = null,
|
||||
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
|
||||
{
|
||||
@@ -405,10 +412,12 @@ internal static class StandardPluginRegistrarTestHelpers
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(configuration ?? new ConfigurationBuilder().Build());
|
||||
services.AddSingleton(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore>(new InMemoryLoginAttemptStore());
|
||||
services.AddSingleton<IUserRepository>(new InMemoryUserRepository());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityCredentialAuditContextAccessor>(
|
||||
auditContextAccessor ?? new TestAuthorityCredentialAuditContextAccessor());
|
||||
|
||||
@@ -2,12 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
@@ -15,20 +14,18 @@ using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IDatabase database;
|
||||
private readonly InMemoryUserRepository userRepository;
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly StandardUserCredentialStore store;
|
||||
private readonly TestAuditLogger auditLogger;
|
||||
private readonly Mock<IUserRepository> userRepositoryMock;
|
||||
|
||||
public StandardUserCredentialStoreTests()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
database = client.GetDatabase("authority-tests");
|
||||
options = new StandardPluginOptions
|
||||
{
|
||||
PasswordPolicy = new PasswordPolicyOptions
|
||||
@@ -55,11 +52,11 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
};
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
auditLogger = new TestAuditLogger();
|
||||
userRepositoryMock = new Mock<IUserRepository>();
|
||||
userRepository = new InMemoryUserRepository();
|
||||
store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"test-tenant",
|
||||
userRepositoryMock.Object,
|
||||
userRepository,
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
@@ -67,7 +64,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
|
||||
{
|
||||
auditLogger.Reset();
|
||||
@@ -95,7 +92,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
|
||||
{
|
||||
auditLogger.Reset();
|
||||
@@ -144,7 +141,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
|
||||
{
|
||||
auditLogger.Reset();
|
||||
@@ -156,19 +153,24 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Iterations = 160_000
|
||||
});
|
||||
|
||||
var document = new StandardUserDocument
|
||||
await userRepository.CreateAsync(new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "test-tenant",
|
||||
Username = "legacy",
|
||||
NormalizedUsername = "legacy",
|
||||
Email = "legacy@local",
|
||||
DisplayName = "Legacy",
|
||||
PasswordHash = legacyHash,
|
||||
Roles = new List<string>(),
|
||||
Attributes = new Dictionary<string, string?>(),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.InsertOneAsync(document);
|
||||
PasswordSalt = "",
|
||||
Enabled = true,
|
||||
Metadata = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["subjectId"] = "legacy",
|
||||
["roles"] = new List<string>(),
|
||||
["attributes"] = new Dictionary<string, string?>(),
|
||||
["requirePasswordReset"] = false
|
||||
})
|
||||
});
|
||||
|
||||
var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
|
||||
|
||||
@@ -180,16 +182,14 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.True(auditEntry.Success);
|
||||
Assert.Equal("legacy", auditEntry.Username);
|
||||
|
||||
var results = await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.FindAsync(u => u.NormalizedUsername == "legacy");
|
||||
var updated = results.FirstOrDefault();
|
||||
var updated = await userRepository.GetByUsernameAsync("test-tenant", "legacy", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
|
||||
{
|
||||
auditLogger.Reset();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Authority Standard Plugin Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0097-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Standard.Tests. |
|
||||
| AUDIT-0097-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Standard.Tests. |
|
||||
| AUDIT-0097-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
internal sealed class InMemoryUserRepository : IUserRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, UserEntity> users = new();
|
||||
private readonly Dictionary<string, Guid> byUsername = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, Guid> byEmail = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var created = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = user.Enabled,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = string.IsNullOrWhiteSpace(user.Settings) ? "{}" : user.Settings,
|
||||
Metadata = string.IsNullOrWhiteSpace(user.Metadata) ? "{}" : user.Metadata,
|
||||
CreatedAt = user.CreatedAt == default ? now : user.CreatedAt,
|
||||
UpdatedAt = user.UpdatedAt == default ? now : user.UpdatedAt,
|
||||
CreatedBy = user.CreatedBy
|
||||
};
|
||||
|
||||
users[created.Id] = created;
|
||||
byUsername[GetUsernameKey(created.TenantId, created.Username)] = created.Id;
|
||||
byEmail[GetEmailKey(created.TenantId, created.Email)] = created.Id;
|
||||
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (users.TryGetValue(id, out var user) && string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<UserEntity?>(user);
|
||||
}
|
||||
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetUsernameKey(tenantId, username);
|
||||
if (byUsername.TryGetValue(key, out var id) && users.TryGetValue(id, out var user))
|
||||
{
|
||||
return Task.FromResult<UserEntity?>(user);
|
||||
}
|
||||
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetEmailKey(tenantId, email);
|
||||
if (byEmail.TryGetValue(key, out var id) && users.TryGetValue(id, out var user))
|
||||
{
|
||||
return Task.FromResult<UserEntity?>(user);
|
||||
}
|
||||
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? enabled = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = users.Values
|
||||
.Where(u => string.Equals(u.TenantId, tenantId, StringComparison.Ordinal))
|
||||
.Where(u => enabled is null || u.Enabled == enabled.Value)
|
||||
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<UserEntity>>(results);
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!users.TryGetValue(user.Id, out var existing))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = user.Enabled,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = string.IsNullOrWhiteSpace(user.Settings) ? existing.Settings : user.Settings,
|
||||
Metadata = string.IsNullOrWhiteSpace(user.Metadata) ? existing.Metadata : user.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = user.CreatedBy ?? existing.CreatedBy
|
||||
};
|
||||
|
||||
users[updated.Id] = updated;
|
||||
byUsername[GetUsernameKey(updated.TenantId, updated.Username)] = updated.Id;
|
||||
byEmail[GetEmailKey(updated.TenantId, updated.Email)] = updated.Id;
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (users.TryGetValue(id, out var user) && string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
users.Remove(id);
|
||||
byUsername.Remove(GetUsernameKey(user.TenantId, user.Username));
|
||||
byEmail.Remove(GetEmailKey(user.TenantId, user.Email));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(
|
||||
string tenantId,
|
||||
Guid userId,
|
||||
string passwordHash,
|
||||
string passwordSalt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = new UserEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
TenantId = existing.TenantId,
|
||||
Username = existing.Username,
|
||||
Email = existing.Email,
|
||||
DisplayName = existing.DisplayName,
|
||||
PasswordHash = passwordHash,
|
||||
PasswordSalt = passwordSalt,
|
||||
Enabled = existing.Enabled,
|
||||
EmailVerified = existing.EmailVerified,
|
||||
MfaEnabled = existing.MfaEnabled,
|
||||
MfaSecret = existing.MfaSecret,
|
||||
MfaBackupCodes = existing.MfaBackupCodes,
|
||||
FailedLoginAttempts = existing.FailedLoginAttempts,
|
||||
LockedUntil = existing.LockedUntil,
|
||||
LastLoginAt = existing.LastLoginAt,
|
||||
PasswordChangedAt = now,
|
||||
Settings = existing.Settings,
|
||||
Metadata = existing.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
users[updated.Id] = updated;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(
|
||||
string tenantId,
|
||||
Guid userId,
|
||||
DateTimeOffset? lockUntil = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var attempts = existing.FailedLoginAttempts + 1;
|
||||
var updated = new UserEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
TenantId = existing.TenantId,
|
||||
Username = existing.Username,
|
||||
Email = existing.Email,
|
||||
DisplayName = existing.DisplayName,
|
||||
PasswordHash = existing.PasswordHash,
|
||||
PasswordSalt = existing.PasswordSalt,
|
||||
Enabled = existing.Enabled,
|
||||
EmailVerified = existing.EmailVerified,
|
||||
MfaEnabled = existing.MfaEnabled,
|
||||
MfaSecret = existing.MfaSecret,
|
||||
MfaBackupCodes = existing.MfaBackupCodes,
|
||||
FailedLoginAttempts = attempts,
|
||||
LockedUntil = lockUntil,
|
||||
LastLoginAt = existing.LastLoginAt,
|
||||
PasswordChangedAt = existing.PasswordChangedAt,
|
||||
Settings = existing.Settings,
|
||||
Metadata = existing.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
users[updated.Id] = updated;
|
||||
return Task.FromResult(attempts);
|
||||
}
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!users.TryGetValue(userId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = new UserEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
TenantId = existing.TenantId,
|
||||
Username = existing.Username,
|
||||
Email = existing.Email,
|
||||
DisplayName = existing.DisplayName,
|
||||
PasswordHash = existing.PasswordHash,
|
||||
PasswordSalt = existing.PasswordSalt,
|
||||
Enabled = existing.Enabled,
|
||||
EmailVerified = existing.EmailVerified,
|
||||
MfaEnabled = existing.MfaEnabled,
|
||||
MfaSecret = existing.MfaSecret,
|
||||
MfaBackupCodes = existing.MfaBackupCodes,
|
||||
FailedLoginAttempts = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = now,
|
||||
PasswordChangedAt = existing.PasswordChangedAt,
|
||||
Settings = existing.Settings,
|
||||
Metadata = existing.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
users[updated.Id] = updated;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GetUsernameKey(string tenantId, string username)
|
||||
=> $"{tenantId}::{username}".ToLowerInvariant();
|
||||
|
||||
private static string GetEmailKey(string tenantId, string email)
|
||||
=> $"{tenantId}::{email}".ToLowerInvariant();
|
||||
}
|
||||
Reference in New Issue
Block a user