Refactor and update test projects, remove obsolete tests, and upgrade dependencies
- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory. - Removed unused TestDataFactory class. - Updated project files for Mongo.Tests to remove references to deleted files. - Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects. - Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project. - Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library. - Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries. - Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious. - Updated JsonSchema.Net package to version 7.3.2 in Microservice project. - Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
@@ -6,6 +6,8 @@ namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginOptions
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
public BootstrapUserOptions? BootstrapUser { get; set; }
|
||||
|
||||
public PasswordPolicyOptions PasswordPolicy { get; set; } = new();
|
||||
|
||||
@@ -3,12 +3,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
@@ -16,6 +16,8 @@ namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
public string PluginType => "standard";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
@@ -27,12 +29,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
@@ -43,21 +45,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var userRepository = sp.GetRequiredService<IUserRepository>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
@@ -73,15 +75,19 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
database,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
// Use tenant from options or default
|
||||
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
tenantId,
|
||||
userRepository,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,45 +2,44 @@ 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;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
{
|
||||
private readonly IMongoCollection<StandardUserDocument> users;
|
||||
private readonly IUserRepository userRepository;
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly IPasswordHasher passwordHasher;
|
||||
private readonly IStandardCredentialAuditLogger auditLogger;
|
||||
private readonly ILogger<StandardUserCredentialStore> logger;
|
||||
private readonly string pluginName;
|
||||
private readonly string tenantId;
|
||||
|
||||
public StandardUserCredentialStore(
|
||||
string pluginName,
|
||||
IMongoDatabase database,
|
||||
string tenantId,
|
||||
IUserRepository userRepository,
|
||||
StandardPluginOptions options,
|
||||
IPasswordHasher passwordHasher,
|
||||
IStandardCredentialAuditLogger auditLogger,
|
||||
ILogger<StandardUserCredentialStore> logger)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
this.userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
|
||||
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collectionName = $"authority_users_{pluginName.ToLowerInvariant()}";
|
||||
users = database.GetCollection<StandardUserDocument>(collectionName);
|
||||
EnsureIndexes();
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
|
||||
@@ -56,11 +55,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
}
|
||||
|
||||
var normalized = NormalizeUsername(username);
|
||||
var user = await users.Find(u => u.NormalizedUsername == normalized)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
var userEntity = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (user is null)
|
||||
if (userEntity is null)
|
||||
{
|
||||
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
|
||||
await RecordAuditAsync(
|
||||
@@ -74,7 +72,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
|
||||
var user = MapToDocument(userEntity);
|
||||
|
||||
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
|
||||
{
|
||||
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
|
||||
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
|
||||
@@ -101,12 +101,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
auditProperties);
|
||||
}
|
||||
|
||||
var verification = passwordHasher.Verify(password, user.PasswordHash);
|
||||
var verification = passwordHasher.Verify(password, userEntity.PasswordHash ?? string.Empty);
|
||||
if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.PasswordHash = passwordHasher.Hash(password);
|
||||
var newHash = passwordHasher.Hash(password);
|
||||
await userRepository.UpdatePasswordAsync(tenantId, userEntity.Id, newHash, "", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.rehashed",
|
||||
@@ -114,13 +116,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
});
|
||||
}
|
||||
|
||||
var previousFailures = user.Lockout.FailedAttempts;
|
||||
ResetLockout(user);
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await users.ReplaceOneAsync(
|
||||
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
|
||||
user,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var previousFailures = userEntity.FailedLoginAttempts;
|
||||
await userRepository.RecordSuccessfulLoginAsync(tenantId, userEntity.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (previousFailures > 0)
|
||||
{
|
||||
@@ -146,23 +144,27 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
auditProperties);
|
||||
}
|
||||
|
||||
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
|
||||
await RegisterFailureAsync(userEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var code = options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockout
|
||||
// Re-fetch to get updated lockout state
|
||||
var updatedUser = await userRepository.GetByIdAsync(tenantId, userEntity.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var code = options.Lockout.Enabled && updatedUser?.LockedUntil is { } lockout
|
||||
? AuthorityCredentialFailureCode.LockedOut
|
||||
: AuthorityCredentialFailureCode.InvalidCredentials;
|
||||
|
||||
TimeSpan? retry = user.Lockout.LockoutEnd is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
|
||||
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
|
||||
? lockoutTime - DateTimeOffset.UtcNow
|
||||
: null;
|
||||
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.failed_attempts",
|
||||
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
|
||||
Value = ClassifiedString.Public((updatedUser?.FailedLoginAttempts ?? 0).ToString(CultureInfo.InvariantCulture))
|
||||
});
|
||||
|
||||
if (user.Lockout.LockoutEnd is { } pendingLockout)
|
||||
if (updatedUser?.LockedUntil is { } pendingLockout)
|
||||
{
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
@@ -207,8 +209,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await users.Find(u => u.NormalizedUsername == normalized)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
var existing = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
@@ -218,57 +219,79 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
|
||||
}
|
||||
|
||||
var document = new StandardUserDocument
|
||||
var metadata = new Dictionary<string, object?>
|
||||
{
|
||||
Username = registration.Username,
|
||||
NormalizedUsername = normalized,
|
||||
DisplayName = registration.DisplayName,
|
||||
Email = registration.Email,
|
||||
PasswordHash = passwordHasher.Hash(registration.Password!),
|
||||
RequirePasswordReset = registration.RequirePasswordReset,
|
||||
Roles = registration.Roles.ToList(),
|
||||
Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
["subjectId"] = Guid.NewGuid().ToString("N"),
|
||||
["roles"] = registration.Roles.ToList(),
|
||||
["attributes"] = registration.Attributes,
|
||||
["requirePasswordReset"] = registration.RequirePasswordReset
|
||||
};
|
||||
|
||||
await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
|
||||
var newUser = new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Username = normalized,
|
||||
Email = registration.Email ?? $"{normalized}@local",
|
||||
DisplayName = registration.DisplayName,
|
||||
PasswordHash = passwordHasher.Hash(registration.Password!),
|
||||
PasswordSalt = "",
|
||||
Enabled = true,
|
||||
Metadata = JsonSerializer.Serialize(metadata)
|
||||
};
|
||||
|
||||
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
|
||||
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(created)));
|
||||
}
|
||||
|
||||
existing.Username = registration.Username;
|
||||
existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
|
||||
existing.Email = registration.Email ?? existing.Email;
|
||||
existing.Roles = registration.Roles.Any()
|
||||
? registration.Roles.ToList()
|
||||
: existing.Roles;
|
||||
// Update existing user
|
||||
var existingMetadata = ParseMetadata(existing.Metadata);
|
||||
|
||||
if (registration.Roles.Any())
|
||||
{
|
||||
existingMetadata["roles"] = registration.Roles.ToList();
|
||||
}
|
||||
|
||||
if (registration.Attributes.Count > 0)
|
||||
{
|
||||
var attrs = existingMetadata.TryGetValue("attributes", out var existingAttrs) && existingAttrs is Dictionary<string, string?> dict
|
||||
? dict
|
||||
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pair in registration.Attributes)
|
||||
{
|
||||
existing.Attributes[pair.Key] = pair.Value;
|
||||
attrs[pair.Key] = pair.Value;
|
||||
}
|
||||
existingMetadata["attributes"] = attrs;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(registration.Password))
|
||||
{
|
||||
existing.PasswordHash = passwordHasher.Hash(registration.Password!);
|
||||
existing.RequirePasswordReset = registration.RequirePasswordReset;
|
||||
await userRepository.UpdatePasswordAsync(tenantId, existing.Id, passwordHasher.Hash(registration.Password!), "", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
existingMetadata["requirePasswordReset"] = registration.RequirePasswordReset;
|
||||
}
|
||||
else if (registration.RequirePasswordReset)
|
||||
{
|
||||
existing.RequirePasswordReset = true;
|
||||
existingMetadata["requirePasswordReset"] = true;
|
||||
}
|
||||
|
||||
existing.UpdatedAt = now;
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
TenantId = tenantId,
|
||||
Username = normalized,
|
||||
Email = registration.Email ?? existing.Email,
|
||||
DisplayName = registration.DisplayName ?? existing.DisplayName,
|
||||
PasswordHash = existing.PasswordHash,
|
||||
PasswordSalt = existing.PasswordSalt,
|
||||
Enabled = existing.Enabled,
|
||||
Metadata = JsonSerializer.Serialize(existingMetadata)
|
||||
};
|
||||
|
||||
await users.ReplaceOneAsync(
|
||||
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
|
||||
existing,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
|
||||
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(updatedUser, existingMetadata)));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
@@ -278,11 +301,21 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await users.Find(u => u.SubjectId == subjectId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
// We need to search by subjectId which is stored in metadata
|
||||
// For now, get all users and filter - in production, add a dedicated query
|
||||
var users = await userRepository.GetAllAsync(tenantId, enabled: null, limit: 1000, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return user is null ? null : ToDescriptor(user);
|
||||
foreach (var user in users)
|
||||
{
|
||||
var metadata = ParseMetadata(user.Metadata);
|
||||
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
|
||||
{
|
||||
return ToDescriptor(MapToDocument(user, metadata));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
|
||||
@@ -312,19 +345,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
public Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new BsonDocument("ping", 1);
|
||||
await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return AuthorityPluginHealthResult.Healthy();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
|
||||
return AuthorityPluginHealthResult.Unavailable(ex.Message);
|
||||
}
|
||||
// PostgreSQL health is checked at infrastructure level
|
||||
return Task.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
private string? ValidatePassword(string password)
|
||||
@@ -357,33 +381,76 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
|
||||
private async Task RegisterFailureAsync(UserEntity user, CancellationToken cancellationToken)
|
||||
{
|
||||
user.Lockout.LastFailure = DateTimeOffset.UtcNow;
|
||||
user.Lockout.FailedAttempts += 1;
|
||||
DateTimeOffset? lockUntil = null;
|
||||
|
||||
if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
|
||||
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
|
||||
{
|
||||
user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
|
||||
user.Lockout.FailedAttempts = 0;
|
||||
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
|
||||
}
|
||||
|
||||
await users.ReplaceOneAsync(
|
||||
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
|
||||
user,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ResetLockout(StandardUserDocument user)
|
||||
{
|
||||
user.Lockout.FailedAttempts = 0;
|
||||
user.Lockout.LockoutEnd = null;
|
||||
user.Lockout.LastFailure = null;
|
||||
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
=> username.Trim().ToLowerInvariant();
|
||||
|
||||
private static StandardUserDocument MapToDocument(UserEntity entity, Dictionary<string, object?>? metadata = null)
|
||||
{
|
||||
metadata ??= ParseMetadata(entity.Metadata);
|
||||
|
||||
var subjectId = metadata.TryGetValue("subjectId", out var sid) ? sid?.ToString() ?? entity.Id.ToString("N") : entity.Id.ToString("N");
|
||||
var roles = metadata.TryGetValue("roles", out var r) && r is JsonElement rolesElement
|
||||
? rolesElement.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
|
||||
: new List<string>();
|
||||
var attrs = metadata.TryGetValue("attributes", out var a) && a is JsonElement attrsElement
|
||||
? attrsElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString(), StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
var requireReset = metadata.TryGetValue("requirePasswordReset", out var rr) && rr is JsonElement rrElement && rrElement.GetBoolean();
|
||||
|
||||
return new StandardUserDocument
|
||||
{
|
||||
Id = entity.Id,
|
||||
SubjectId = subjectId,
|
||||
Username = entity.Username,
|
||||
NormalizedUsername = entity.Username.ToLowerInvariant(),
|
||||
PasswordHash = entity.PasswordHash ?? string.Empty,
|
||||
DisplayName = entity.DisplayName,
|
||||
Email = entity.Email,
|
||||
RequirePasswordReset = requireReset,
|
||||
Roles = roles,
|
||||
Attributes = attrs!,
|
||||
Lockout = new StandardLockoutState
|
||||
{
|
||||
FailedAttempts = entity.FailedLoginAttempts,
|
||||
LockoutEnd = entity.LockedUntil,
|
||||
LastFailure = entity.FailedLoginAttempts > 0 ? entity.UpdatedAt : null
|
||||
},
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ParseMetadata(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json)
|
||||
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
|
||||
=> new(
|
||||
document.SubjectId,
|
||||
@@ -393,25 +460,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
document.Roles,
|
||||
document.Attributes);
|
||||
|
||||
private void EnsureIndexes()
|
||||
{
|
||||
var indexKeys = Builders<StandardUserDocument>.IndexKeys
|
||||
.Ascending(u => u.NormalizedUsername);
|
||||
|
||||
var indexModel = new CreateIndexModel<StandardUserDocument>(
|
||||
indexKeys,
|
||||
new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
|
||||
|
||||
try
|
||||
{
|
||||
users.Indexes.CreateOne(indexModel);
|
||||
}
|
||||
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RecordAuditAsync(
|
||||
string normalizedUsername,
|
||||
string? subjectId,
|
||||
|
||||
@@ -1,64 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardUserDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("normalizedUsername")]
|
||||
public string NormalizedUsername { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("passwordHash")]
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("email")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[BsonElement("requirePasswordReset")]
|
||||
public bool RequirePasswordReset { get; set; }
|
||||
|
||||
[BsonElement("roles")]
|
||||
public List<string> Roles { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("lockout")]
|
||||
public StandardLockoutState Lockout { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal sealed class StandardLockoutState
|
||||
{
|
||||
[BsonElement("failedAttempts")]
|
||||
public int FailedAttempts { get; set; }
|
||||
|
||||
[BsonElement("lockoutEnd")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? LockoutEnd { get; set; }
|
||||
|
||||
[BsonElement("lastFailure")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? LastFailure { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user