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:
master
2025-12-10 19:13:29 +02:00
parent a3c7fe5e88
commit b7059d523e
369 changed files with 11125 additions and 14245 deletions

View File

@@ -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();

View File

@@ -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 =>
{

View File

@@ -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>

View File

@@ -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,

View File

@@ -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; }
}