up
Some checks failed
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
root
2025-10-10 06:53:40 +00:00
parent 3aed135fb5
commit df5984d07e
1081 changed files with 97764 additions and 61389 deletions

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<!--
If the caller passes -p:CopyPluginBinariesToPath=… we respect that.
Otherwise we fall back to the local relative folder used in Visual Studio.
-->
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','Ablera.Serdica.Authority','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<!-- Fires after **every** successful build, CLI or IDE -->
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<!-- Every file that just landed in the target directory -->
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<Message Importance="high" Text="Copying files: @(BuiltFiles->'%(RecursiveDir)%(Filename)%(Extension)') &#xD;&#xA; to: $(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="true" />
</Target>
<ItemGroup>
<Content Include="useraccount-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.Authentication/Ablera.Serdica.Authentication.csproj" />
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.DBModels.Serdica/Ablera.Serdica.DBModels.Serdica.csproj" />
<ProjectReference Include="..\Ablera.Serdica.Authority.Plugins.Base\Ablera.Serdica.Authority.Plugins.Base.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,124 @@
using Ablera.Serdica.DBModels.Serdica;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using Ablera.Serdica.Common.Tools.Extensions;
using Microsoft.EntityFrameworkCore;
using Ablera.Serdica.Authority.Plugin.Standard.Models;
namespace Ablera.Serdica.Authority.Plugin.Standard;
public class IdentityManagementFacade(
SerdicaDbContext context,
IUserManagementFacade<UserAccountIdentityUser> userAccountIdentityFacade)
: IUserManagementFacade<IdentityUser<string>>
{
#region IAuthService
public async Task<AuthenticationResult> AuthenticateAsync(IdentityUser<string> identityUser,
string password,
bool lockoutOnFailure = false,
CancellationToken ct = default)
{
var user = await context.UserAccounts
.FirstOrDefaultAsync(u => u.UserGuid == identityUser.Id, ct);
if (user == null)
return AuthenticationResult.Fail(AuthenticationCode.AccountIsNotFound.ToScreamingSnakeCase());
if (string.IsNullOrWhiteSpace(password))
return AuthenticationResult.Fail(AuthenticationCode.EmptyCredentials.ToScreamingSnakeCase());
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = user };
return await userAccountIdentityFacade.AuthenticateAsync(userAccountIdentityUser, password, lockoutOnFailure, ct);
}
#endregion
#region IUserStore besteffort mapping
public async Task<IdentityUser<string>?> FindByEmailAsync(string email, CancellationToken ct = default)
=> (await userAccountIdentityFacade.FindByEmailAsync(email, ct))?.Identity;
public async Task<IdentityUser<string>?> FindByNameAsync(string username, CancellationToken ct = default)
=> (await userAccountIdentityFacade.FindByNameAsync(username, ct))?.Identity;
public async Task<IdentityUser<string>?> FindByIdAsync(string id, CancellationToken ct = default)
=> (await userAccountIdentityFacade.FindByIdAsync(id, ct))?.Identity;
public Task<OperationResult> CreateAsync(IdentityUser<string> identityUser, string password, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.CreateAsync(userAccountIdentityUser, password, ct);
}
public Task<OperationResult> UpdateAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.UpdateAsync(userAccountIdentityUser, ct);
}
#endregion
#region Claim helpers
public Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.GetBaseClaimsAsync(userAccountIdentityUser, ct);
}
public Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.GetRolesClaimsAsync(userAccountIdentityUser, ct);
}
#endregion
#region Password & lock operations delegated
public Task<OperationResult> ChangePasswordAsync(IdentityUser<string> identityUser, string currentPassword, string newPassword, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.ChangePasswordAsync(userAccountIdentityUser, currentPassword, newPassword, ct);
}
public Task<OperationResult> ResetPasswordAsync(IdentityUser<string> identityUser, string token, string newPassword, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.ResetPasswordAsync(userAccountIdentityUser, token, newPassword, ct);
}
public Task<OperationResult> LockAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.LockAsync(userAccountIdentityUser, ct);
}
public Task<OperationResult> UnlockAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
if (identityUser == null) return Task.FromResult(OperationResult.Fail("NULL_USER"));
var userAccountIdentityUser = new UserAccountIdentityUser { Identity = identityUser, UserAccount = null! };
return userAccountIdentityFacade.UnlockAsync(userAccountIdentityUser, ct);
}
#endregion
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authority.Plugin.Standard.Models;
public record Credentials
{
public required string Username { get; init; }
public required string Password { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authority.Plugin.Standard.Models;
public record DefaultCredentials
{
public required string Confirmation { get; init; }
public Credentials[]? Accounts { get; init; }
}

View File

@@ -0,0 +1,10 @@
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.AspNetCore.Identity;
namespace Ablera.Serdica.Authority.Plugin.Standard.Models;
public record UserAccountIdentityUser
{
public required IdentityUser<string> Identity { get; init; }
public required UserAccount UserAccount { get; init; }
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Plugin.Standard.Models;
public record UserAccountSettings
{
public bool LockoutEnabled { get; set; } = true;
public int LockoutThreshold { get; set; } = 5;
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15);
public int SaltSize { get; set; } = 32;
public DefaultCredentials? DefaultCredentials { get; set; }
}

View File

@@ -0,0 +1,20 @@
using Ablera.Serdica.Plugin.Contracts;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Ablera.Serdica.Authority.Services;
using Ablera.Serdica.Authority.Plugin.Standard;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.AspNetCore.Identity;
using Ablera.Serdica.Authority.Plugin.Standard.Models;
namespace Ablera.Serdica.Identity.Plugin.Ldap;
public class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
=> services
.AddSingleton<UserAccountSettingsProvider>()
.AddScoped<IUserManagementFacade<UserAccountIdentityUser>, UserAccountIdentityFacade>()
.AddScoped<IUserManagementFacade<IdentityUser<string>>, IdentityManagementFacade>();
}

View File

@@ -0,0 +1,385 @@
using Ablera.Serdica.Authentication.Extensions;
using Ablera.Serdica.Common.Tools.Extensions;
using Ablera.Serdica.DBModels.Serdica;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Ablera.Serdica.Authority.Plugin.Standard.Models;
using Ablera.Serdica.Authority.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using static Ablera.Serdica.Authority.Plugins.Base.Constants.ConstantsClass;
namespace Ablera.Serdica.Authority.Plugin.Standard;
/// <summary>
/// Concrete implementation that satisfies the refined <see cref="IUserManager{TUser,TKey}"/> façade and its
/// constituent smaller interfaces. The DB schema is Serdicaspecific (Oracle) but all higher layers only see
/// the contracts defined in <c>Base.Contracts</c>.
/// </summary>
public sealed class UserAccountIdentityFacade(
UserAccountSettingsProvider settingsProvider,
IHttpContextAccessor httpContextAccessor,
IWebHostEnvironment webHostEnvironment,
SerdicaDbContext context) :
IUserManagementFacade<UserAccountIdentityUser>
{
#region IAuthService
public async Task<AuthenticationResult> AuthenticateAsync(UserAccountIdentityUser identityUser,
string password,
bool lockoutOnFailure = false,
CancellationToken ct = default)
{
// already locked ?
if (identityUser.UserAccount.LockAccount == YesKey)
{
await RecordAttemptAsync(identityUser.UserAccount.UserAccountId, false, ct);
return AuthenticationResult.Fail(AuthenticationCode.AccountIsLocked.ToScreamingSnakeCase());
}
var pwd = await context.UserAccountPasswords
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserAccountId == identityUser.UserAccount.UserAccountId, ct);
pwd ??= await UpsertDefaultDevelopmentEnvironmentPassword(identityUser.UserAccount, settingsProvider.Settings, ct);
var success = pwd is not null && VerifyPassword(password, pwd.HashedPassword, pwd.Salt);
await RecordAttemptAsync(identityUser.UserAccount.UserAccountId, success, ct);
if (!success)
{
if (lockoutOnFailure)
await EvaluateAndLockAsync(identityUser, settingsProvider.Settings, ct);
return AuthenticationResult.Fail(AuthenticationCode.InvalidCredentials.ToScreamingSnakeCase());
}
// paranoia — make sure lock flag cleared if necessary
if (identityUser.UserAccount.LockAccount == YesKey)
await UnlockAsync(identityUser, ct);
var claims = await GetBaseClaimsAsync(identityUser, ct);
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, typeof(UserAccountIdentityFacade).Namespace));
return AuthenticationResult.Success(claimsPrincipal);
}
#endregion
#region IUserStore
public async Task<OperationResult> CreateAsync(UserAccountIdentityUser identityUser, string password, CancellationToken ct = default)
{
if (identityUser is null) return OperationResult.Fail("NULL_USER");
if (string.IsNullOrWhiteSpace(password)) return OperationResult.Fail("EMPTY_PASSWORD");
var user = new UserAccount
{
UserGuid = identityUser.Identity.Id,
UserName = identityUser.Identity.UserName,
UserEmail = identityUser.Identity.Email,
LockAccount = NoKey,
};
await context.UserAccounts.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
var salt = GenerateSalt(settingsProvider.Settings);
var hashed = HashPassword(password, salt);
await context.UserAccountPasswords.AddAsync(new UserAccountPassword
{
UserAccountId = user.UserAccountId,
Salt = salt,
HashedPassword = hashed,
CreatedDate = DateTime.UtcNow
}, ct);
await context.SaveChangesAsync(ct);
return OperationResult.Success();
}
public async Task<OperationResult> UpdateAsync(UserAccountIdentityUser identityUser, CancellationToken ct = default)
{
await context.UserAccounts.Where(x => x.UserGuid == identityUser.Identity.Id)
.ExecuteUpdateAsync(q => q.SetProperty(x => x.UserName, identityUser.Identity.UserName)
.SetProperty(x => x.LockAccount, identityUser.Identity.LockoutEnabled ? YesKey : NoKey)
.SetProperty(x => x.UserEmail, identityUser.Identity.Email));
await context.SaveChangesAsync(ct);
return OperationResult.Success();
}
public async Task<UserAccountIdentityUser?> FindByIdAsync(string id, CancellationToken ct = default)
{
var userAccount = await context.UserAccounts
.Include(u => u.UserRole1s)
.Include(u => u.UserGroup1s)
.Include(u => u.SrCust)
.ThenInclude(u => u.CPerson)
.FirstOrDefaultAsync(u => u.UserGuid == id, ct);
if (userAccount == null)
return null;
return new UserAccountIdentityUser
{
Identity = new IdentityUser
{
Id = userAccount.UserGuid,
UserName = userAccount.UserName,
Email = userAccount.UserEmail,
LockoutEnabled = userAccount.LockAccount == YesKey,
EmailConfirmed = true,
PhoneNumberConfirmed = true
},
UserAccount = userAccount
};
}
public async Task<UserAccountIdentityUser?> FindByEmailAsync(string email, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(email))
return null;
var userAccount = await context.UserAccounts
.Include(u => u.UserRole1s)
.Include(u => u.UserGroup1s)
.FirstOrDefaultAsync(u => u.UserEmail.ToLower() == email.ToLower(), ct);
if (userAccount == null)
return null;
return new UserAccountIdentityUser
{
Identity = new IdentityUser
{
Id = userAccount.UserGuid,
UserName = userAccount.UserName,
Email = userAccount.UserEmail,
LockoutEnabled = userAccount.LockAccount == YesKey,
EmailConfirmed = true,
PhoneNumberConfirmed = true
},
UserAccount = userAccount
};
}
public async Task<UserAccountIdentityUser?> FindByNameAsync(string username, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return null;
var userAccount = await context.UserAccounts
.Include(u => u.UserRole1s)
.Include(u => u.UserGroup1s)
.FirstOrDefaultAsync(u => u.UserName.ToLower() == username.ToLower(), ct);
if (userAccount == null)
return null;
return new UserAccountIdentityUser
{
Identity = new IdentityUser
{
Id = userAccount.UserGuid,
UserName = userAccount.UserName,
Email = userAccount.UserEmail,
LockoutEnabled = userAccount.LockAccount == YesKey,
EmailConfirmed = true,
PhoneNumberConfirmed = true
},
UserAccount = userAccount
};
}
#endregion
#region IClaimStore
public Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(UserAccountIdentityUser identityUser, CancellationToken ct = default)
=> Task.FromResult(
identityUser.Identity.BuildClaims(identityUser.Identity.Email, identityUser.UserAccount?.SrCust?.CPerson?.Gname, identityUser.UserAccount?.SrCust?.CPerson?.Fname));
public async Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(UserAccountIdentityUser identityUser, CancellationToken ct = default)
{
Claim[]? roleClaims = null;
if (string.IsNullOrWhiteSpace(identityUser.Identity.Id) == false)
{
roleClaims = await context.UserAccounts
.Include(x => x.UserRole1s)
.Where(x => x.UserGuid == identityUser.Identity.Id)
.SelectMany(x => x.UserRole1s.Select(y => y.Id))
.Select(roleId => new Claim(ClaimTypes.Role, roleId))
.ToArrayAsync(ct);
}
else if (string.IsNullOrWhiteSpace(identityUser.Identity.Email) == false)
{
roleClaims = await context.UserAccounts
.Include(x => x.UserRole1s)
.Where(x => x.UserEmail == identityUser.Identity.Email)
.SelectMany(x => x.UserRole1s.Select(y => y.Id))
.Select(roleId => new Claim(ClaimTypes.Role, roleId))
.ToArrayAsync(ct);
}
else if (string.IsNullOrWhiteSpace(identityUser.Identity.UserName) == false)
{
roleClaims = await context.UserAccounts
.Include(x => x.UserRole1s)
.Where(x => x.UserName == identityUser.Identity.UserName)
.SelectMany(x => x.UserRole1s.Select(y => y.Id))
.Select(roleId => new Claim(ClaimTypes.Role, roleId))
.ToArrayAsync(ct);
}
return roleClaims ?? [];
}
#endregion
#region IPasswordManager
public async Task<OperationResult> ChangePasswordAsync(UserAccountIdentityUser identityUser,
string currentPassword,
string newPassword,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(newPassword)) return OperationResult.Fail("EMPTY_NEW_PASSWORD");
var pwd = await context.UserAccountPasswords.FirstOrDefaultAsync(p => p.UserAccount.UserGuid == identityUser.Identity.Id, ct);
if (pwd is null) return OperationResult.Fail("PWD_ROW_NOT_FOUND");
if (!VerifyPassword(currentPassword, pwd.HashedPassword, pwd.Salt))
return OperationResult.Fail("INVALID_CURRENT_PASSWORD");
pwd.Salt = GenerateSalt(settingsProvider.Settings);
pwd.HashedPassword = HashPassword(newPassword, pwd.Salt);
pwd.CreatedDate = DateTime.UtcNow;
context.UserAccountPasswords.Update(pwd);
await context.SaveChangesAsync(ct);
return OperationResult.Success();
}
public async Task<OperationResult> ResetPasswordAsync(UserAccountIdentityUser identityUser,
string resetToken,
string newPassword,
CancellationToken ct = default)
{
// TODO: validate token (email/SMS/etc.)
return await ChangePasswordAsync(identityUser, currentPassword: string.Empty, newPassword, ct);
}
#endregion
#region IAccountLockManager
public async Task<OperationResult> LockAsync(UserAccountIdentityUser identityUser, CancellationToken ct = default)
{
if (!settingsProvider.Settings.LockoutEnabled) return OperationResult.Success();
var updated = await context.UserAccounts.Where(u => u.UserGuid == identityUser.Identity.Id)
.ExecuteUpdateAsync(q => q.SetProperty(x => x.LockAccount, YesKey)
.SetProperty(x => x.LockAccountDate, DateTime.UtcNow), ct);
return updated > 0 ? OperationResult.Success() : OperationResult.Fail("LOCK_FAILED");
}
public async Task<OperationResult> UnlockAsync(UserAccountIdentityUser identityUser, CancellationToken ct = default)
{
var updated = await context.UserAccounts.Where(u => u.UserGuid == identityUser.Identity.Id)
.ExecuteUpdateAsync(q => q.SetProperty(x => x.LockAccount, NoKey)
.SetProperty(x => x.LockAccountDate, (DateTime?)null), ct);
return updated > 0 ? OperationResult.Success() : OperationResult.Fail("UNLOCK_FAILED");
}
#endregion
#region Helpers attempts & lock evaluation
private string? GetClientIp()
{
var http = httpContextAccessor.HttpContext;
if (http is null) return null; // background thread etc.
// After UseForwardedHeaders this is usually all you need
var ip = http.Connection.RemoteIpAddress?.ToString();
// Fallback (e.g. you skipped the middleware or have multiple proxies)
if (string.IsNullOrWhiteSpace(ip) &&
http.Request.Headers.TryGetValue("X-Forwarded-For", out var h))
{
ip = h.ToString().Split(',')[0].Trim(); // first hop = client
}
return ip;
}
private async Task RecordAttemptAsync(decimal userAccountId, bool success, CancellationToken ct)
{
await context.UserLoginAttempts.AddAsync(new UserLoginAttempt
{
UserAccountId = userAccountId,
AttemptDate = DateTime.UtcNow,
Result = success ? YesKey : NoKey,
IpAddress = GetClientIp()
}, ct);
await context.SaveChangesAsync(ct);
}
private async Task EvaluateAndLockAsync(UserAccountIdentityUser identityUser, UserAccountSettings settings, CancellationToken ct)
{
var windowStart = DateTime.UtcNow - settings.LockoutDuration;
var last = await context.UserLoginAttempts
.Where(a => a.UserAccount.UserGuid == identityUser.Identity.Id && a.AttemptDate >= windowStart)
.OrderByDescending(a => a.AttemptDate)
.Take(settings.LockoutThreshold)
.Select(a => a.Result)
.ToListAsync(ct);
if (last.Count == settings.LockoutThreshold && last.All(r => r == NoKey))
await LockAsync(identityUser, ct);
}
private async Task<UserAccountPassword?> UpsertDefaultDevelopmentEnvironmentPassword(UserAccount userAccount, UserAccountSettings settings, CancellationToken ct)
{
if (webHostEnvironment.IsDevelopment() == false) return null;
if (settings.DefaultCredentials?.Confirmation != "I acknowledge this is not safe!") return null;
var defaultAccount = settings.DefaultCredentials?.Accounts?.FirstOrDefault(x => x.Username == userAccount.UserEmail);
if (defaultAccount?.Password == null) return null;
var salt = GenerateSalt(settings);
var hashed = HashPassword(defaultAccount.Password!, salt);
var userAccountPassword = new UserAccountPassword
{
UserAccountId = userAccount.UserAccountId,
Salt = salt,
HashedPassword = hashed,
CreatedDate = DateTime.UtcNow
};
await context.UserAccountPasswords.AddAsync(userAccountPassword, ct);
await context.SaveChangesAsync(ct);
return userAccountPassword;
}
#endregion
#region Crypto helpers
private string GenerateSalt(UserAccountSettings settings)
{
var bytes = new byte[settings.SaltSize];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes);
}
private static string HashPassword(string plain, string salt)
{
using var sha = SHA256.Create();
var combined = salt + plain;
return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(combined)));
}
private static bool VerifyPassword(string plain, string hashed, string salt) =>
HashPassword(plain, salt) == hashed;
#endregion
}

View File

@@ -0,0 +1,27 @@
using Ablera.Serdica.Common.Tools;
using Ablera.Serdica.Common.Tools.Models.Config;
using Ablera.Serdica.Authority.Plugin.Standard.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Services;
public class UserAccountSettingsProvider : GenericJsonSettingsProvider<UserAccountSettings>
{
public const string JsonFileName = "useraccount-settings.json";
public static readonly string JsonFilePath =
Path.GetDirectoryName(typeof(UserAccountSettingsProvider).Assembly.Location)
?? AppContext.BaseDirectory;
public UserAccountSettingsProvider(
ILogger<GenericJsonSettingsProvider<UserAccountSettings>> logger,
IOptions<JsonFileSettingsConfig> options)
: base(logger, options, JsonFileName, null, JsonFilePath)
{
}
}

View File

@@ -0,0 +1,19 @@
{
"LockoutEnabled": true,
"LockoutThreshold": 5,
"LockoutDuration": "00:15:00",
"SaltSize": 32,
"DefaultCredentials": {
"Confirmation": "I acknowledge this is not safe!",
"Accounts": [
{
"Username": "admin@ablera.com",
"Password": "demodemo"
},
{
"Username": "dev@ablera.com",
"Password": "demodemo"
}
]
}
}