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
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:
@@ -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)') 
 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>
|
||||
@@ -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 – best‑effort 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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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 Serdica‑specific (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
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user