From 026469df18cae17277b8d042833f4f7c2b5002e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 27 Feb 2026 15:35:57 +0300 Subject: [PATCH 01/29] Sample Improvement (Part 2/2) --- .../Program.cs | 2 + .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Options/UAuthLoginIdentifierOptions.cs | 7 +- .../Infrastructure/LoginIdentifierResolver.cs | 5 +- .../Services/UserApplicationService.cs | 77 ++++++++++++++----- 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 19af773b..4e8a600d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -17,6 +17,7 @@ using MudExtensions.Services; using Scalar.AspNetCore; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; var builder = WebApplication.CreateBuilder(args); @@ -46,6 +47,7 @@ o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); o.UserIdentifiers.AllowMultipleUsernames = true; + o.LoginIdentifiers.AllowedTypes = new HashSet() { UserIdentifierType.Username, UserIdentifierType.Email }; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 4c55fe37..12aab1c6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -252,7 +252,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.Configure(opt => { - opt.AllowedBuiltIns = new HashSet + opt.AllowedTypes = new HashSet { UserIdentifierType.Username, UserIdentifierType.Email diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs index 450ce64e..5d316bad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthLoginIdentifierOptions { - public ISet AllowedBuiltIns { get; set; } = + public ISet AllowedTypes { get; set; } = new HashSet { UserIdentifierType.Username, @@ -17,12 +17,15 @@ public sealed class UAuthLoginIdentifierOptions public bool EnableCustomResolvers { get; set; } = true; public bool CustomResolversFirst { get; set; } = true; + public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false; + internal UAuthLoginIdentifierOptions Clone() => new() { - AllowedBuiltIns = new HashSet(AllowedBuiltIns), + AllowedTypes = new HashSet(AllowedTypes), RequireVerificationForEmail = RequireVerificationForEmail, RequireVerificationForPhone = RequireVerificationForPhone, EnableCustomResolvers = EnableCustomResolvers, CustomResolversFirst = CustomResolversFirst, + EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs index 918e00e7..b134b9a0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -39,7 +39,7 @@ public LoginIdentifierResolver( var builtInType = DetectBuiltInType(normalized); - if (!_options.AllowedBuiltIns.Contains(builtInType)) + if (!_options.AllowedTypes.Contains(builtInType)) { if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) return await TryCustomAsync(tenant, normalized, ct); @@ -112,8 +112,7 @@ public LoginIdentifierResolver( return null; } - private static string Normalize(string identifier) - => identifier.Trim(); + private static string Normalize(string identifier) => identifier.Trim(); private static UserIdentifierType DetectBuiltInType(string normalized) { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 08089c61..037e8fec 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -17,7 +17,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; private readonly IEnumerable _integrations; - private readonly UAuthUserIdentifierOptions _identifierOptions; + private readonly UAuthServerOptions _options; private readonly IClock _clock; public UserApplicationService( @@ -34,7 +34,7 @@ public UserApplicationService( _profileStore = profileStore; _identifierStore = identifierStore; _integrations = integrations; - _identifierOptions = options.Value.UserIdentifiers; + _options = options.Value; _clock = clock; } @@ -229,6 +229,17 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie EnsureVerificationRequirements(request.Type, isVerified: false ); } + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (request.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(request.Type)); + + if (mustBeUnique) + { + var exists = await _identifierStore.ExistsAsync(context.ResourceTenant, request.Type, request.Value, innerCt); + + if (exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { @@ -256,7 +267,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde EnsureOverrideAllowed(context); - if (identifier.Type == UserIdentifierType.Username && !_identifierOptions.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_options.UserIdentifiers.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } @@ -264,6 +275,17 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(identifier.Value, request.NewValue, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (identifier.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(identifier.Type)); + + if (mustBeUnique) + { + var existing = await _identifierStore.GetAsync(identifier.Tenant, identifier.Type, request.NewValue, innerCt); + + if (existing is not null && existing.Id != identifier.Id && !existing.IsDeleted) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, _clock.UtcNow, innerCt); }); @@ -282,6 +304,20 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); + var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); + + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_primary"); + + if (_options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers) + { + var exists = await _identifierStore.ExistsAsync(identifier.Tenant, identifier.Type, identifier.Value, innerCt); + + if (exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); }); @@ -303,14 +339,18 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - var otherLoginIdentifiers = identifiers - .Where(i => !i.IsDeleted && - IsLoginIdentifier(i.Type) && - i.Id != identifier.Id) - .ToList(); + var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); + + var primaryLoginIdentifiers = activeIdentifiers + .Where(i => + i.IsPrimary && + _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) + .ToList(); - if (otherLoginIdentifiers.Count == 0) - throw new UAuthIdentifierConflictException("cannot_unset_last_primary_login_identifier"); + if (primaryLoginIdentifiers.Count == 1 && primaryLoginIdentifiers[0].Id == identifier.Id) + { + throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); + } await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, innerCt); }); @@ -345,8 +385,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (identifier.IsPrimary) throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); - if (_identifierOptions.RequireUsernameIdentifier && - identifier.Type == UserIdentifierType.Username) + if (_options.UserIdentifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) { var activeUsernames = identifiers .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) @@ -419,24 +458,24 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL if (!hasSameType) return; - if (type == UserIdentifierType.Username && !_identifierOptions.AllowMultipleUsernames) + if (type == UserIdentifierType.Username && !_options.UserIdentifiers.AllowMultipleUsernames) throw new InvalidOperationException("multiple_usernames_not_allowed"); - if (type == UserIdentifierType.Email && !_identifierOptions.AllowMultipleEmail) + if (type == UserIdentifierType.Email && !_options.UserIdentifiers.AllowMultipleEmail) throw new InvalidOperationException("multiple_emails_not_allowed"); - if (type == UserIdentifierType.Phone && !_identifierOptions.AllowMultiplePhone) + if (type == UserIdentifierType.Phone && !_options.UserIdentifiers.AllowMultiplePhone) throw new InvalidOperationException("multiple_phones_not_allowed"); } private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { - if (type == UserIdentifierType.Email && _identifierOptions.RequireEmailVerification && !isVerified) + if (type == UserIdentifierType.Email && _options.UserIdentifiers.RequireEmailVerification && !isVerified) { throw new InvalidOperationException("email_verification_required"); } - if (type == UserIdentifierType.Phone && _identifierOptions.RequirePhoneVerification && !isVerified) + if (type == UserIdentifierType.Phone && _options.UserIdentifiers.RequirePhoneVerification && !isVerified) { throw new InvalidOperationException("phone_verification_required"); } @@ -444,10 +483,10 @@ private void EnsureVerificationRequirements(UserIdentifierType type, bool isVeri private void EnsureOverrideAllowed(AccessContext context) { - if (context.IsSelfAction && !_identifierOptions.AllowUserOverride) + if (context.IsSelfAction && !_options.UserIdentifiers.AllowUserOverride) throw new InvalidOperationException("user_override_not_allowed"); - if (!context.IsSelfAction && !_identifierOptions.AllowAdminOverride) + if (!context.IsSelfAction && !_options.UserIdentifiers.AllowAdminOverride) throw new InvalidOperationException("admin_override_not_allowed"); } From 588da385cf3eda2424e7ba5a6488eee384aca6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 27 Feb 2026 17:01:50 +0300 Subject: [PATCH 02/29] Identifier Normalization & Improved Exists Logic --- .../Contracts/Common/CaseHandling.cs | 8 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Normalizer/IIdentifierNormalizer.cs | 8 + .../Normalizer/IdentifierNormalizer.cs | 132 ++++++++++ .../Normalizer/NormalizedIdentifier.cs | 7 + .../Options/UAuthLoginIdentifierOptions.cs | 22 +- .../Dtos/IdentifierExistenceQuery.cs | 13 + .../Dtos/IdentifierExistenceScope.cs | 19 ++ .../Dtos/UserIdentifierDto.cs | 1 + .../Responses/IdentifierExistenceResult.cs | 10 + .../InMemoryUserSeedContributor.cs | 7 + .../Stores/InMemoryUserIdentifierStore.cs | 87 ++++--- .../Domain/UserIdentifier.cs | 1 + .../Mapping/UserIdentifierMapper.cs | 1 + .../Services/IUserApplicationService.cs | 2 +- .../Services/UserApplicationService.cs | 231 ++++++++++++------ .../Stores/IUserIdentifierStore.cs | 8 +- 17 files changed, 433 insertions(+), 125 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs new file mode 100644 index 00000000..08e57fe5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum CaseHandling +{ + Preserve, + ToLower, + ToUpper +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 12aab1c6..bad7115b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -208,6 +208,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs new file mode 100644 index 00000000..80f15028 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierNormalizer +{ + NormalizedIdentifier Normalize(UserIdentifierType type, string value); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs new file mode 100644 index 00000000..0fd00be7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs @@ -0,0 +1,132 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Globalization; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierNormalizer : IIdentifierNormalizer +{ + private readonly UAuthIdentifierNormalizationOptions _options; + + public IdentifierNormalizer(IOptions options) + { + _options = options.Value.LoginIdentifiers.Normalization; + } + + public NormalizedIdentifier Normalize(UserIdentifierType type, string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new(value, string.Empty, false, "identifier_empty"); + + var raw = value; + var normalized = BasicNormalize(value); + + return type switch + { + UserIdentifierType.Email => NormalizeEmail(raw, normalized), + UserIdentifierType.Phone => NormalizePhone(raw, normalized), + UserIdentifierType.Username => NormalizeUsername(raw, normalized), + _ => NormalizeCustom(raw, normalized) + }; + } + + private static string BasicNormalize(string value) + { + var form = value.Normalize(NormalizationForm.FormKC).Trim(); + + var sb = new StringBuilder(form.Length); + foreach (var ch in form) + { + if (char.IsControl(ch)) + continue; + + if (ch is '\u200B' or '\u200C' or '\u200D' or '\uFEFF') + continue; + + sb.Append(ch); + } + + return sb.ToString(); + } + + private NormalizedIdentifier NormalizeUsername(string raw, string value) + { + if (value.Length < 3 || value.Length > 256) + return new(raw, value, false, "username_invalid_length"); + + value = ApplyCasePolicy(value, _options.UsernameCase); + + return new(raw, value, true, null); + } + + private NormalizedIdentifier NormalizeEmail(string raw, string value) + { + var atIndex = value.IndexOf('@'); + if (atIndex <= 0 || atIndex != value.LastIndexOf('@')) + return new(raw, value, false, "email_invalid_format"); + + var local = value[..atIndex]; + var domain = value[(atIndex + 1)..]; + + if (string.IsNullOrWhiteSpace(domain) || !domain.Contains('.')) + return new(raw, value, false, "email_invalid_domain"); + + try + { + var idn = new IdnMapping(); + domain = idn.GetAscii(domain); + } + catch + { + return new(raw, value, false, "email_invalid_domain"); + } + + var normalized = $"{local}@{domain}"; + normalized = ApplyCasePolicy(normalized, _options.EmailCase); + + return new(raw, normalized, true, null); + } + + private NormalizedIdentifier NormalizePhone(string raw, string value) + { + var sb = new StringBuilder(); + + foreach (var ch in value) + { + if (char.IsDigit(ch)) + sb.Append(ch); + else if (ch == '+' && sb.Length == 0) + sb.Append(ch); + } + + var digits = sb.ToString(); + + if (digits.Length < 7) + return new(raw, digits, false, "phone_invalid_length"); + + return new(raw, digits, true, null); + } + + private NormalizedIdentifier NormalizeCustom(string raw, string value) + { + value = ApplyCasePolicy(value, _options.CustomCase); + + if (value.Length == 0) + return new(raw, value, false, "identifier_invalid"); + + return new(raw, value, true, null); + } + + private static string ApplyCasePolicy(string value, CaseHandling policy) + { + return policy switch + { + CaseHandling.ToLower => value.ToLowerInvariant(), + CaseHandling.ToUpper => value.ToUpperInvariant(), + _ => value + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs new file mode 100644 index 00000000..9bf3d7b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public readonly record struct NormalizedIdentifier( + string Raw, + string Normalized, + bool IsValid, + string? ErrorCode); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs index 5d316bad..5b6c5032 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthLoginIdentifierOptions @@ -17,6 +18,8 @@ public sealed class UAuthLoginIdentifierOptions public bool EnableCustomResolvers { get; set; } = true; public bool CustomResolversFirst { get; set; } = true; + public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new(); + public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false; internal UAuthLoginIdentifierOptions Clone() => new() @@ -26,6 +29,21 @@ public sealed class UAuthLoginIdentifierOptions RequireVerificationForPhone = RequireVerificationForPhone, EnableCustomResolvers = EnableCustomResolvers, CustomResolversFirst = CustomResolversFirst, - EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers + EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers, + Normalization = Normalization.Clone() + }; +} + +public sealed class UAuthIdentifierNormalizationOptions +{ + public CaseHandling UsernameCase { get; set; } = CaseHandling.Preserve; + public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower; + public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve; + + internal UAuthIdentifierNormalizationOptions Clone() => new() + { + UsernameCase = UsernameCase, + EmailCase = EmailCase, + CustomCase = CustomCase }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs new file mode 100644 index 00000000..7906dd6e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceQuery( + TenantKey Tenant, + UserIdentifierType Type, + string NormalizedValue, + IdentifierExistenceScope Scope, + UserKey? UserKey = null, + Guid? ExcludeIdentifierId = null + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs new file mode 100644 index 00000000..ba2d8f33 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs @@ -0,0 +1,19 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum IdentifierExistenceScope +{ + /// + /// Checks only within the same user. + /// + WithinUser, + + /// + /// Checks within tenant but only primary identifiers. + /// + TenantPrimaryOnly, + + /// + /// Checks within tenant regardless of primary flag. + /// + TenantAny +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index a4d91eec..a5b05ee5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -5,6 +5,7 @@ public sealed record UserIdentifierDto public Guid Id { get; set; } public required UserIdentifierType Type { get; set; } public required string Value { get; set; } + public string NormalizedValue { get; set; } = default!; public bool IsPrimary { get; set; } public bool IsVerified { get; set; } public DateTimeOffset CreatedAt { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs new file mode 100644 index 00000000..ec234ff4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceResult( + bool Exists, + UserKey? OwnerUserKey = null, + Guid? OwnerIdentifierId = null, + bool OwnerIsPrimary = false + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index c5312f51..b1a390ca 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -15,6 +16,7 @@ internal sealed class InMemoryUserSeedContributor : ISeedContributor private readonly IUserProfileStore _profiles; private readonly IUserIdentifierStore _identifiers; private readonly IInMemoryUserIdProvider _ids; + private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IClock _clock; public InMemoryUserSeedContributor( @@ -22,12 +24,14 @@ public InMemoryUserSeedContributor( IUserProfileStore profiles, IUserIdentifierStore identifiers, IInMemoryUserIdProvider ids, + IIdentifierNormalizer identifierNormalizer, IClock clock) { _lifecycle = lifecycle; _profiles = profiles; _ids = ids; _identifiers = identifiers; + _identifierNormalizer = identifierNormalizer; _clock = clock; } @@ -69,6 +73,7 @@ await _identifiers.CreateAsync(tenant, UserKey = userKey, Type = UserIdentifierType.Username, Value = primaryUsername, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryUsername).Normalized, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow @@ -82,6 +87,7 @@ await _identifiers.CreateAsync(tenant, UserKey = userKey, Type = UserIdentifierType.Email, Value = primaryEmail, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryEmail).Normalized, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow @@ -95,6 +101,7 @@ await _identifiers.CreateAsync(tenant, UserKey = userKey, Type = UserIdentifierType.Phone, Value = primaryPhone, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryPhone).Normalized, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index b18a1272..096d70ff 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -11,27 +11,44 @@ public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { private readonly Dictionary _store = new(); - public Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var exists = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == type && - x.Value == value && - !x.IsDeleted); + var candidates = _store.Values + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); + + if (query.ExcludeIdentifierId.HasValue) + candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + candidates = query.Scope switch + { + IdentifierExistenceScope.WithinUser => candidates.Where(x => x.UserKey == query.UserKey), + IdentifierExistenceScope.TenantPrimaryOnly => candidates.Where(x => x.IsPrimary), + IdentifierExistenceScope.TenantAny => candidates, + _ => candidates + }; - return Task.FromResult(exists); + var match = candidates.FirstOrDefault(); + + if (match is null) + return Task.FromResult(new IdentifierExistenceResult(false)); + + return Task.FromResult(new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); } - public Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default) + public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var identifier = _store.Values.FirstOrDefault(x => x.Tenant == tenant && x.Type == type && - x.Value == value && + x.NormalizedValue == normalizedValue && !x.IsDeleted); return Task.FromResult(identifier); @@ -72,15 +89,6 @@ public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, Cancellatio if (identifier.Id == Guid.Empty) identifier.Id = Guid.NewGuid(); - var duplicate = _store.Values.Any(x => - x.Tenant == tenant && - x.Type == identifier.Type && - x.Value == identifier.Value && - !x.IsDeleted); - - if (duplicate) - throw new UAuthConflictException("identifier_already_exists"); - identifier.Tenant = tenant; _store[identifier.Id] = identifier; @@ -88,27 +96,19 @@ public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, Cancellatio return Task.CompletedTask; } - public Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task UpdateValueAsync(Guid id, string newRawValue, string newNormalizedValue, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + throw new UAuthIdentifierNotFoundException("identifier_not_found"); - if (identifier.Value == newValue) - throw new InvalidOperationException("identifier_value_unchanged"); + if (identifier.NormalizedValue == newNormalizedValue) + throw new UAuthIdentifierConflictException("identifier_value_unchanged"); - var duplicate = _store.Values.Any(x => - x.Id != id && - x.Tenant == identifier.Tenant && - x.Type == identifier.Type && - x.Value == newValue && - !x.IsDeleted); + identifier.Value = newRawValue; + identifier.NormalizedValue = newNormalizedValue; - if (duplicate) - throw new InvalidOperationException("identifier_value_already_exists"); - - identifier.Value = newValue; identifier.IsVerified = false; identifier.VerifiedAt = null; identifier.UpdatedAt = updatedAt; @@ -121,7 +121,7 @@ public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationTo ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.IsVerified) return Task.CompletedTask; @@ -133,7 +133,7 @@ public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationTo return Task.CompletedTask; } - public Task SetPrimaryAsync(Guid id, CancellationToken ct = default) + public Task SetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -144,25 +144,35 @@ public Task SetPrimaryAsync(Guid id, CancellationToken ct = default) x.Tenant == target.Tenant && x.UserKey == target.UserKey && x.Type == target.Type && - x.IsPrimary)) + x.IsPrimary && + !x.IsDeleted)) { + if (idf.Id == target.Id) + continue; idf.IsPrimary = false; + idf.UpdatedAt = updatedAt; } target.IsPrimary = true; + target.UpdatedAt = updatedAt; return Task.CompletedTask; } - public Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default) + public Task UnsetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (!identifier.IsPrimary) + { + throw new UAuthIdentifierConflictException("identifier_is_not_primary_already"); + } identifier.IsPrimary = false; - identifier.UpdatedAt = DateTimeOffset.UtcNow; + identifier.UpdatedAt = updatedAt; return Task.CompletedTask; } @@ -211,6 +221,7 @@ public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode identifier.IsDeleted = true; identifier.DeletedAt = deletedAt; identifier.IsPrimary = false; + identifier.UpdatedAt = deletedAt; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 5d06adc5..eeacccdd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -14,6 +14,7 @@ public sealed record UserIdentifier public UserIdentifierType Type { get; init; } // Email, Phone, Username public string Value { get; set; } = default!; + public string NormalizedValue { get; set; } = default!; public bool IsPrimary { get; set; } public bool IsVerified { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index e999d2d5..b37a3954 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -10,6 +10,7 @@ public static UserIdentifierDto ToDto(UserIdentifier record) Id = record.Id, Type = record.Type, Value = record.Value, + NormalizedValue = record.NormalizedValue, IsPrimary = record.IsPrimary, IsVerified = record.IsVerified, CreatedAt = record.CreatedAt, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index b01bc97f..6bb41630 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -18,7 +18,7 @@ public interface IUserApplicationService Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); - Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default); Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 037e8fec..dea7e574 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -17,6 +17,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; private readonly IEnumerable _integrations; + private readonly IIdentifierNormalizer _identifierNormalizer; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -26,6 +27,7 @@ public UserApplicationService( IUserProfileStore profileStore, IUserIdentifierStore identifierStore, IEnumerable integrations, + IIdentifierNormalizer identifierNormalizer, IOptions options, IClock clock) { @@ -34,36 +36,12 @@ public UserApplicationService( _profileStore = profileStore; _identifierStore = identifierStore; _integrations = integrations; + _identifierNormalizer = identifierNormalizer; _options = options.Value; _clock = clock; } - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) - { - var command = new GetMeCommand(async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); - }); - - return await _accessOrchestrator.ExecuteAsync(context, command, ct); - } - - public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) - { - var command = new GetUserProfileCommand(async innerCt => - { - // Target user MUST exist in context - var targetUserKey = context.GetTargetUserKey(); - - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); - - }); - - return await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region User Lifecycle public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) { @@ -163,6 +141,58 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + var command = new DeleteUserCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + #endregion + + + #region User Profile + + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetMeCommand(async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetUserProfileCommand(async innerCt => + { + // Target user MUST exist in context + var targetUserKey = context.GetTargetUserKey(); + + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); + + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) { var command = new UpdateUserProfileCommand(async innerCt => @@ -176,6 +206,9 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #endregion + + #region Identifiers public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) @@ -202,11 +235,16 @@ public async Task> GetIdentifiersByUserAsync(Ac return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default) { var command = new UserIdentifierExistsCommand(async innerCt => { - return await _identifierStore.ExistsAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return false; + + var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope), innerCt); + return result.Exists; }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -216,36 +254,57 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var command = new AddUserIdentifierCommand(async innerCt => { + EnsureOverrideAllowed(context); var userKey = context.GetTargetUserKey(); + var normalized = _identifierNormalizer.Normalize(request.Type, request.Value); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + var existing = await _identifierStore.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - EnsureOverrideAllowed(context); EnsureMultipleIdentifierAllowed(request.Type, existing); - if (request.IsPrimary) - { - // new identifiers are not verified by default, so we check against the requirement even if the request doesn't explicitly set it to true. - // This prevents adding a primary identifier that doesn't meet verification requirements. - EnsureVerificationRequirements(request.Type, isVerified: false ); - } + var userScopeResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(context.ResourceTenant, request.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, UserKey: userKey), innerCt); + + if (userScopeResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || (request.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(request.Type)); if (mustBeUnique) { - var exists = await _identifierStore.ExistsAsync(context.ResourceTenant, request.Type, request.Value, innerCt); + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var globalResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + context.ResourceTenant, + request.Type, + normalized.Normalized, + scope), + innerCt); - if (exists) + if (globalResult.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists"); } + if (request.IsPrimary) + { + // new identifiers are not verified by default, so we check against the requirement even if the request doesn't explicitly set it to true. + // This prevents adding a primary identifier that doesn't meet verification requirements. + EnsureVerificationRequirements(request.Type, isVerified: false); + } + await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { UserKey = userKey, Type = request.Type, Value = request.Value, + NormalizedValue = normalized.Normalized, IsPrimary = request.IsPrimary, IsVerified = false, CreatedAt = _clock.UtcNow @@ -260,19 +319,23 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde { var command = new UpdateUserIdentifierCommand(async innerCt => { + EnsureOverrideAllowed(context); + var identifier = await _identifierStore.GetByIdAsync(request.Id, innerCt); if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - EnsureOverrideAllowed(context); - if (identifier.Type == UserIdentifierType.Username && !_options.UserIdentifiers.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } - if (string.Equals(identifier.Value, request.NewValue, StringComparison.Ordinal)) + var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + + if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || @@ -280,13 +343,24 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (mustBeUnique) { - var existing = await _identifierStore.GetAsync(identifier.Tenant, identifier.Type, request.NewValue, innerCt); + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var result = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Tenant, + identifier.Type, + normalized.Normalized, + scope, + ExcludeIdentifierId: identifier.Id), + innerCt); - if (existing is not null && existing.Id != identifier.Id && !existing.IsDeleted) + if (result.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists"); } - await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, _clock.UtcNow, innerCt); + await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, normalized.Normalized, resetVerification: true, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -302,21 +376,33 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar if (identifier is null) throw new UAuthIdentifierNotFoundException("identifier_not_found"); + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_primary"); + EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); + //var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + //var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); - if (identifier.IsPrimary) - throw new UAuthIdentifierValidationException("identifier_already_primary"); + var result = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery(identifier.Tenant, identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); - if (_options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers) - { - var exists = await _identifierStore.ExistsAsync(identifier.Tenant, identifier.Type, identifier.Value, innerCt); + if (result.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); - if (exists) - throw new UAuthIdentifierConflictException("identifier_already_exists"); - } + //var userIdentifiers = + //await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); + + //var existingPrimaryOfSameType = userIdentifiers + // .FirstOrDefault(i => + // !i.IsDeleted && + // i.Type == identifier.Type && + // i.IsPrimary); + + //if (existingPrimaryOfSameType is not null) + //{ + // await _identifierStore.UnsetPrimaryAsync(existingPrimaryOfSameType.Id, innerCt); + //} await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); }); @@ -337,17 +423,18 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr if (!identifier.IsPrimary) throw new UAuthIdentifierValidationException("identifier_not_primary"); - var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - - var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); + var userIdentifiers = + await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - var primaryLoginIdentifiers = activeIdentifiers - .Where(i => - i.IsPrimary && - _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) - .ToList(); + var activeLoginPrimaries = userIdentifiers + .Where(i => + !i.IsDeleted && + i.IsPrimary && + _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) + .ToList(); - if (primaryLoginIdentifiers.Count == 1 && primaryLoginIdentifiers[0].Id == identifier.Id) + if (activeLoginPrimaries.Count == 1 && + activeLoginPrimaries[0].Id == identifier.Id) { throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); } @@ -406,25 +493,8 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #endregion - public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) - { - var command = new DeleteUserCommand(async innerCt => - { - var targetUserKey = context.GetTargetUserKey(); - var now = _clock.UtcNow; - - await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - - foreach (var integration in _integrations) - { - await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); - } - }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); - } + #region Helpers private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { @@ -505,4 +575,5 @@ UserIdentifierType.Username or UserIdentifierType.Email or UserIdentifierType.Phone; + #endregion } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 7b7aaa2a..2f0a7d72 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserIdentifierStore { - Task ExistsAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); + Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); @@ -16,13 +16,13 @@ public interface IUserIdentifierStore Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - Task UpdateValueAsync(Guid id, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); + Task UpdateValueAsync(Guid id, string newRawValue, string newNormalizedValue, DateTimeOffset updatedAt, CancellationToken ct = default); Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default); - Task SetPrimaryAsync(Guid id, CancellationToken ct = default); + Task SetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default); - Task UnsetPrimaryAsync(Guid id, CancellationToken ct = default); + Task UnsetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default); Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); From 77a11bc743812b4c16670fd54d0789a101a5ba8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 27 Feb 2026 21:19:58 +0300 Subject: [PATCH 03/29] Complete Identifier and Add New Tests --- .../Components/Dialogs/IdentifierDialog.razor | 13 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../InMemoryUserSeedContributor.cs | 45 +- .../Stores/InMemoryUserIdentifierStore.cs | 14 + .../Services/UserApplicationService.cs | 46 ++- .../Helpers/TestAccessContext.cs | 18 + .../Helpers/TestAuthRuntime.cs | 6 + .../UserIdentifierApplicationServiceTests.cs | 383 ++++++++++++++++++ 8 files changed, 481 insertions(+), 46 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index 6aba7ba5..aaa9acaf 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -54,11 +54,12 @@ - + - + + Add @@ -75,6 +76,7 @@ @code { private UserIdentifierType _newIdentifierType; private string? _newIdentifierValue; + private bool _newIdentifierPrimary; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; @@ -113,9 +115,11 @@ } else { - Snackbar.Add("Failed to update identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); } + var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); + _identifiers = getResult.Value?.ToList() ?? new List(); return DataGridEditFormAction.Close; } @@ -130,7 +134,8 @@ AddUserIdentifierRequest request = new() { Type = _newIdentifierType, - Value = _newIdentifierValue + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary }; var result = await UAuthClient.Identifiers.AddSelfAsync(request); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index bad7115b..f08a5c70 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -208,7 +208,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddSingleton(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index b1a390ca..bc7b2bf5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -41,8 +41,7 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) await SeedUserAsync(tenant, _ids.GetUserUserId(), "Standard User", "user", "user@ultimateauth.com", "9876543210", ct); } - private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string primaryUsername, - string primaryEmail, string primaryPhone, CancellationToken ct) + private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, string email, string phone, CancellationToken ct) { if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) return; @@ -65,19 +64,23 @@ await _profiles.CreateAsync(tenant, CreatedAt = _clock.UtcNow }, ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = primaryUsername, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryUsername).Normalized, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + var usernameIdentifier = new UserIdentifier + { + Id = Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = username, + NormalizedValue = _identifierNormalizer + .Normalize(UserIdentifierType.Username, username) + .Normalized, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }; + + await _identifiers.CreateAsync(tenant, usernameIdentifier, ct); + await _identifiers.SetPrimaryAsync(usernameIdentifier.Id, _clock.UtcNow, ct); await _identifiers.CreateAsync(tenant, new UserIdentifier @@ -86,8 +89,10 @@ await _identifiers.CreateAsync(tenant, Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Email, - Value = primaryEmail, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryEmail).Normalized, + Value = email, + NormalizedValue = _identifierNormalizer + .Normalize(UserIdentifierType.Email, email) + .Normalized, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow @@ -100,8 +105,10 @@ await _identifiers.CreateAsync(tenant, Tenant = tenant, UserKey = userKey, Type = UserIdentifierType.Phone, - Value = primaryPhone, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, primaryPhone).Normalized, + Value = phone, + NormalizedValue = _identifierNormalizer + .Normalize(UserIdentifierType.Phone, phone) + .Normalized, IsPrimary = true, IsVerified = true, CreatedAt = _clock.UtcNow diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 096d70ff..66d68aba 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -91,6 +91,20 @@ public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, Cancellatio identifier.Tenant = tenant; + if (identifier.IsPrimary) + { + foreach (var existing in _store.Values.Where(x => + x.Tenant == tenant && + x.UserKey == identifier.UserKey && + x.Type == identifier.Type && + x.IsPrimary && + !x.IsDeleted)) + { + existing.IsPrimary = false; + existing.UpdatedAt = identifier.CreatedAt; + } + } + _store[identifier.Id] = identifier; return Task.CompletedTask; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index dea7e574..ac665f8e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -228,7 +228,11 @@ public async Task> GetIdentifiersByUserAsync(Ac { var command = new GetUserIdentifierCommand(async innerCt => { - var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, value, innerCt); + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return null; + + var identifier = await _identifierStore.GetAsync(context.ResourceTenant, type, normalized.Normalized, innerCt); return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); }); @@ -243,7 +247,9 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde if (!normalized.IsValid) return false; - var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope), innerCt); + UserKey? userKey = scope == IdentifierExistenceScope.WithinUser ? context.GetTargetUserKey() : null; + + var result = await _identifierStore.ExistsAsync(new IdentifierExistenceQuery(context.ResourceTenant, type, normalized.Normalized, scope, userKey), innerCt); return result.Exists; }); @@ -338,6 +344,19 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); + var withinUserResult = await _identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Tenant, + identifier.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + UserKey: identifier.UserKey, + ExcludeIdentifierId: identifier.Id), + innerCt); + + if (withinUserResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || (identifier.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(identifier.Type)); @@ -360,7 +379,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde throw new UAuthIdentifierConflictException("identifier_already_exists"); } - await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, normalized.Normalized, resetVerification: true, _clock.UtcNow, innerCt); + await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, normalized.Normalized, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -381,30 +400,13 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - //var identifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - //var activeIdentifiers = identifiers.Where(i => !i.IsDeleted).ToList(); - var result = await _identifierStore.ExistsAsync( new IdentifierExistenceQuery(identifier.Tenant, identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); if (result.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists"); - //var userIdentifiers = - //await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); - - //var existingPrimaryOfSameType = userIdentifiers - // .FirstOrDefault(i => - // !i.IsDeleted && - // i.Type == identifier.Type && - // i.IsPrimary); - - //if (existingPrimaryOfSameType is not null) - //{ - // await _identifierStore.UnsetPrimaryAsync(existingPrimaryOfSameType.Id, innerCt); - //} - - await _identifierStore.SetPrimaryAsync(request.IdentifierId, innerCt); + await _identifierStore.SetPrimaryAsync(request.IdentifierId, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -439,7 +441,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); } - await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, innerCt); + await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, _clock.UtcNow, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index 137f63b9..6e1ac0ea 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -19,4 +20,21 @@ public static AccessContext WithAction(string action) attributes: EmptyAttributes.Instance ); } + + public static AccessContext ForUser(UserKey userKey, string action, TenantKey? tenant = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + resource: "identifier", + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 368fa3bb..d5582a60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -67,4 +67,10 @@ public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) var httpContext = TestHttpContext.Create(tenant); return Services.GetRequiredService().CreateAsync(httpContext, AuthFlowType.Login); } + + public IUserApplicationService GetUserApplicationService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs new file mode 100644 index 00000000..425c8fc9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -0,0 +1,383 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using static CodeBeam.UltimateAuth.Server.Defaults.UAuthActions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UserIdentifierApplicationServiceTests +{ + [Fact] + public async Task Adding_new_primary_email_should_replace_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "new@example.com", + IsPrimary = true + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context); + + identifiers.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); + + identifiers.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary).Value.Should().Be("new@example.com"); + } + + [Fact] + public async Task Primary_phone_should_not_be_login_if_not_allowed() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var identifierService = runtime.Services.GetRequiredService(); + var loginOrchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + action: "users.identifiers.add"); + + await identifierService.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+905551111111", + IsPrimary = true + }); + + var result = await loginOrchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "+905551111111", + Secret = "user", + Device = TestDevice.Default() + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Adding_non_primary_should_not_affect_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + var before = await service.GetIdentifiersByUserAsync(context); + var existingPrimaryEmail = before.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "secondary@example.com", + IsPrimary = false + }); + + var after = await service.GetIdentifiersByUserAsync(context); + + after.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .Id.Should().Be(existingPrimaryEmail.Id); + } + + [Fact] + public async Task Updating_value_should_reset_verification() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UpdateSelf); + + var email = (await service.GetIdentifiersByUserAsync(context)) + .Single(x => x.Type == UserIdentifierType.Email); + + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = email.Id, + NewValue = "updated@example.com" + }); + + var updated = (await service.GetIdentifiersByUserAsync(context)) + .Single(x => x.Id == email.Id); + + updated.IsVerified.Should().BeFalse(); + } + + [Fact] + public async Task Non_primary_duplicate_should_be_allowed_when_global_uniqueness_disabled() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + true.Should().BeTrue(); // no exception + } + + [Fact] + public async Task Primary_duplicate_should_fail_when_global_uniqueness_enabled() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers = true; + }); + + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + Func act = async () => + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Unsetting_last_login_identifier_should_fail() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UnsetPrimarySelf); + + var email = (await service.GetIdentifiersByUserAsync(context)) + .Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + Func act = async () => + await service.UnsetPrimaryUserIdentifierAsync(context, + new UnsetPrimaryUserIdentifierRequest + { + IdentifierId = email.Id + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Email_should_be_case_insensitive_by_default() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "Test@Example.com" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "test@example.com" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Username_should_respect_case_policy() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.UserIdentifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context); + + identifiers.Should().Contain(x => x.Value == "UserName"); + } + + [Fact] + public async Task Username_should_be_case_insensitive_when_configured() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.Normalization.UsernameCase = CaseHandling.ToLower; + o.UserIdentifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "username" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Phone_should_be_normalized_to_digits() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+90 (555) 123-45-67" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context); + + identifiers.Should().Contain(x => + x.Type == UserIdentifierType.Phone && + x.Value == "+90 (555) 123-45-67"); + } + + [Fact] + public async Task Updating_to_existing_value_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "one@example.com" + }); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "two@example.com" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context); + var second = identifiers.Single(x => x.Value == "two@example.com"); + + Func act = async () => + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = second.Id, + NewValue = "one@example.com" + }); + + await act.Should().ThrowAsync(); + } + + //[Fact] + //public async Task Same_identifier_in_different_tenants_should_not_conflict() + //{ + // var runtime = new TestAuthRuntime(); + + // var service = runtime.GetUserApplicationService(); + + // var tenant1User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.Single); + // var tenant2User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.FromInternal("other")); + + // await service.AddUserIdentifierAsync(tenant1User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // await service.AddUserIdentifierAsync(tenant2User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // true.Should().BeTrue(); + //} +} + From 9218fa4ad525e59bed17eb88975c6075f2391d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 28 Feb 2026 00:49:18 +0300 Subject: [PATCH 04/29] Complete Needed Identifier Logics & Improved PagedResult --- .../Components/Dialogs/IdentifierDialog.razor | 181 +++++++++++++----- .../Program.cs | 1 - .../Services/IUserIdentifierClient.cs | 4 +- .../Services/UAuthUserIdentifierClient.cs | 14 +- .../Contracts/Common/PageRequest.cs | 30 +++ .../Contracts/Common/PagedResult.cs | 16 +- .../Contracts/Common/UAuthResult.cs | 2 + .../Abstractions/IUserEndpointHandler.cs | 2 + .../Endpoints/UAuthEndpointRegistrar.cs | 6 + .../Requests/IdentifierExistsRequest.cs | 7 + .../Requests/UserIdentifierQuery.cs | 11 ++ .../Responses/IdentifierExistsResponse.cs | 6 + .../Stores/InMemoryUserIdentifierStore.cs | 55 +++++- .../Stores/InMemoryUserLifecycleStore.cs | 29 ++- .../Stores/InMemoryUserProfileStore.cs | 32 +++- .../Commands/GetUserIdentifiersCommand.cs | 11 +- .../Contracts/UserLifecycleQuery.cs | 8 +- .../Contracts/UserProfileQuery.cs | 9 +- .../Domain/UserIdentifier.cs | 2 +- .../Domain/UserLifecycle.cs | 1 + .../Domain/UserProfile.cs | 1 + .../Endpoints/UserEndpointHandler.cs | 65 ++++++- .../Services/IUserApplicationService.cs | 2 +- .../Services/UserApplicationService.cs | 17 +- .../Stores/IUserIdentifierStore.cs | 3 +- .../UserIdentifierApplicationServiceTests.cs | 34 ++-- 26 files changed, 430 insertions(+), 119 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index aaa9acaf..7b95b01f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -1,6 +1,8 @@ -@using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar +@inject IDialogService DialogService @@ -8,10 +10,14 @@ User: @AuthState?.Identity?.DisplayName - + - Identifiers + + Identifiers + + + @@ -22,27 +28,27 @@ - + @if (context.Item.IsPrimary) { - + } else { - + } - + - + @@ -54,18 +60,29 @@ - - - - - - - Add - + + + + + + + + + + + + + + + + + Add + + - - + + Cancel @@ -74,9 +91,12 @@ @code { + private MudDataGrid? _grid; private UserIdentifierType _newIdentifierType; private string? _newIdentifierValue; private bool _newIdentifierPrimary; + private bool _loading = false; + private bool _reloadQueued; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; @@ -84,8 +104,6 @@ [Parameter] public UAuthState AuthState { get; set; } = default!; - private List _identifiers = new(); - protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -95,12 +113,77 @@ var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); if (result != null && result.IsSuccess && result.Value != null) { - _identifiers = result.Value.ToList(); + await ReloadAsync(); StateHasChanged(); } } } + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Identifiers.GetMyIdentifiersAsync(req); + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + private async Task CommittedItemChanges(UserIdentifierDto item) { UpdateUserIdentifierRequest updateRequest = new() @@ -118,8 +201,7 @@ Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); } - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); return DataGridEditFormAction.Close; } @@ -142,8 +224,7 @@ if (result.IsSuccess) { Snackbar.Add("Identifier added successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else @@ -152,37 +233,51 @@ } } - private async Task SetPrimaryAsync(Guid id) + private async Task VerifyAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); + var demoInfo = await DialogService.ShowMessageBoxAsync( + title: "Demo verification", + markupMessage: (MarkupString) + """ + This is a demo action.

+ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. + This will only mark the identifier as verified in UltimateAuth. + """, + yesText: "Verify"); + + if (demoInfo != true) + { + Snackbar.Add("Verification cancelled", Severity.Info); + return; + } + + VerifyUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.VerifySelfAsync(request); if (result.IsSuccess) { - Snackbar.Add("Primary identifier set successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); } } - private async Task VerifyAsync(Guid id) + private async Task SetPrimaryAsync(Guid id) { - VerifyUserIdentifierRequest request = new() { IdentifierId = id }; - var result = await UAuthClient.Identifiers.VerifySelfAsync(request); + SetPrimaryUserIdentifierRequest request = new() { IdentifierId = id }; + var result = await UAuthClient.Identifiers.SetPrimarySelfAsync(request); if (result.IsSuccess) { - Snackbar.Add("Primary identifier verified successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + Snackbar.Add("Primary identifier set successfully", Severity.Success); + await ReloadAsync(); StateHasChanged(); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); } } @@ -193,8 +288,7 @@ if (result.IsSuccess) { Snackbar.Add("Primary identifier unset successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else @@ -210,8 +304,7 @@ if (result.IsSuccess) { Snackbar.Add("Identifier deleted successfully", Severity.Success); - var getResult = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - _identifiers = getResult.Value?.ToList() ?? new List(); + await ReloadAsync(); StateHasChanged(); } else diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 4e8a600d..69d2cefe 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -47,7 +47,6 @@ o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); o.UserIdentifiers.AllowMultipleUsernames = true; - o.LoginIdentifiers.AllowedTypes = new HashSet() { UserIdentifierType.Username, UserIdentifierType.Email }; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs index 7f019423..cc2190e8 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserIdentifierClient { - Task>> GetMyIdentifiersAsync(); + Task>> GetMyIdentifiersAsync(PageRequest? request = null); Task AddSelfAsync(AddUserIdentifierRequest request); Task UpdateSelfAsync(UpdateUserIdentifierRequest request); Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); @@ -14,7 +14,7 @@ public interface IUserIdentifierClient Task VerifySelfAsync(VerifyUserIdentifierRequest request); Task DeleteSelfAsync(DeleteUserIdentifierRequest request); - Task>> GetUserIdentifiersAsync(UserKey userKey); + Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null); Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 7a814c6c..cd5d1098 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -20,10 +20,11 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task>> GetMyIdentifiersAsync() + public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/users/me/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) @@ -62,10 +63,11 @@ public async Task DeleteSelfAsync(DeleteUserIdentifierRequest reque return UAuthResultMapper.From(raw); } - public async Task>> GetUserIdentifiersAsync(UserKey userKey) + public async Task>> GetUserIdentifiersAsync(UserKey userKey, PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/identifiers/get")); - return UAuthResultMapper.FromJson>(raw); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); } public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs new file mode 100644 index 00000000..b236c0e5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public class PageRequest +{ + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 250; + + public string? SortBy { get; init; } + public bool Descending { get; init; } + + public int MaxPageSize { get; init; } = 1000; + + public PageRequest Normalize() + { + var page = PageNumber <= 0 ? 1 : PageNumber; + var size = PageSize <= 0 ? 250 : PageSize; + + if (size > MaxPageSize) + size = MaxPageSize; + + return new PageRequest + { + PageNumber = page, + PageSize = size, + SortBy = SortBy, + Descending = Descending, + MaxPageSize = MaxPageSize + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs index e3b92ad3..854eafa4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -2,12 +2,22 @@ public sealed class PagedResult { - public IReadOnlyList Items { get; } - public int TotalCount { get; } + public IReadOnlyList Items { get; init; } + public int TotalCount { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + public string? SortBy { get; init; } + public bool Descending { get; init; } - public PagedResult(IReadOnlyList items, int totalCount) + public bool HasNext => PageNumber * PageSize < TotalCount; + + public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending) { Items = items; TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + SortBy = sortBy; + Descending = descending; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index be87ad8b..c446070c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -4,6 +4,8 @@ public class UAuthResult { public bool IsSuccess { get; init; } public int Status { get; init; } + public string? CorrelationId { get; init; } + public string? TraceId { get; init; } public UAuthProblem? Problem { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index e543a959..63223f91 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -17,6 +17,7 @@ public interface IUserEndpointHandler Task UpdateUserAsync(UserKey userKey, HttpContext ctx); Task GetMyIdentifiersAsync(HttpContext ctx); + Task IdentifierExistsSelfAsync(HttpContext ctx); Task AddUserIdentifierSelfAsync(HttpContext ctx); Task UpdateUserIdentifierSelfAsync(HttpContext ctx); Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx); @@ -25,6 +26,7 @@ public interface IUserEndpointHandler Task DeleteUserIdentifierSelfAsync(HttpContext ctx); Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx); + Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx); Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 929cd276..9396c317 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -143,6 +143,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/exists", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.IdentifierExistsSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); @@ -165,6 +168,9 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/exists", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.IdentifierExistsAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs new file mode 100644 index 00000000..c2d9ef05 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsRequest +{ + public UserIdentifierType Type { get; set; } + public string Value { get; set; } = default!; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs new file mode 100644 index 00000000..f425570d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserIdentifierQuery : PageRequest +{ + public UserKey? UserKey { get; set; } + + public bool IncludeDeleted { get; init; } = false; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs new file mode 100644 index 00000000..0d2150d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsResponse +{ + public bool Exists { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 66d68aba..bcc21c1f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -82,6 +82,53 @@ public Task> GetByUserAsync(TenantKey tenant, User return Task.FromResult>(result); } + public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); + + var normalized = query.Normalize(); + + var baseQuery = _store.Values + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == query.UserKey.Value); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); + + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => + query.Descending ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } + public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -138,7 +185,7 @@ public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationTo throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.IsVerified) - return Task.CompletedTask; + throw new UAuthIdentifierConflictException("identifier_already_verified"); identifier.IsVerified = true; identifier.VerifiedAt = verifiedAt; @@ -152,7 +199,7 @@ public Task SetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(id, out var target) || target.IsDeleted) - throw new InvalidOperationException("identifier_not_found"); + throw new UAuthIdentifierNotFoundException("identifier_not_found"); foreach (var idf in _store.Values.Where(x => x.Tenant == target.Tenant && @@ -196,7 +243,7 @@ public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, Canc ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(id, out var identifier)) - return Task.CompletedTask; + throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (mode == DeleteMode.Hard) { @@ -205,7 +252,7 @@ public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, Canc } if (identifier.IsDeleted) - return Task.CompletedTask; + throw new UAuthIdentifierConflictException("identifier_already_deleted"); identifier.IsDeleted = true; identifier.DeletedAt = deletedAt; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index aea09138..82ae2629 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -28,6 +28,10 @@ public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationTok public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + var baseQuery = _store.Values .Where(x => x?.UserKey != null) .Where(x => x.Tenant == tenant); @@ -38,16 +42,25 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc if (query.Status != null) baseQuery = baseQuery.Where(x => x.Status == query.Status); - var totalCount = baseQuery.Count(); + baseQuery = query.SortBy switch + { + nameof(UserLifecycle.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); + nameof(UserLifecycle.Status) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Status) + : baseQuery.OrderBy(x => x.Status), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); - return Task.FromResult(new PagedResult(items, totalCount)); + return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); } public Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index a902bc57..4e5deb93 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -27,21 +27,35 @@ public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationTok public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { - var baseQuery = _store.Values.Where(x => x.Tenant == tenant); + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = _store.Values + .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); - var totalCount = baseQuery.Count(); + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), - var items = baseQuery - .OrderBy(x => x.CreatedAt) - .Skip(query.Skip) - .Take(query.Take) - .ToList() - .AsReadOnly(); + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); - return Task.FromResult(new PagedResult(items, totalCount)); + return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); } public Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs index a8219862..fe8e594f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs @@ -1,16 +1,17 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -internal sealed class GetUserIdentifiersCommand : IAccessCommand> +internal sealed class GetUserIdentifiersCommand : IAccessCommand> { - private readonly Func>> _execute; + private readonly Func>> _execute; - public GetUserIdentifiersCommand(Func>> execute) + public GetUserIdentifiersCommand(Func>> execute) { _execute = execute; } - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index a6c6a944..90a7a9ac 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -1,12 +1,10 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycleQuery +public sealed class UserLifecycleQuery : PageRequest { public bool IncludeDeleted { get; init; } public UserStatus? Status { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index d0cd9262..7b2808a9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,9 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Core.Contracts; -public sealed class UserProfileQuery +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } - - public int Skip { get; init; } - public int Take { get; init; } = 50; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index eeacccdd..a546a018 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -4,9 +4,9 @@ namespace CodeBeam.UltimateAuth.Users.Reference; +// TODO: Add concurrency property public sealed record UserIdentifier { - public Guid Id { get; set; } public TenantKey Tenant { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 085a2e71..0337a401 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -4,6 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; +// TODO: Add concurrency property public sealed record class UserLifecycle { public TenantKey Tenant { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 15c9ee22..543230e8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -4,6 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; // TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) +// TODO: Add concurrency property public sealed record class UserProfile { public TenantKey Tenant { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 3b67efe7..48a26ce2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; @@ -173,13 +174,15 @@ public async Task GetMyIdentifiersAsync(HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserIdentifiers.GetSelf, resource: "users", resourceId: flow.UserKey!.Value); - var result = await _users.GetIdentifiersByUserAsync(accessContext,ctx.RequestAborted); + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } @@ -190,17 +193,73 @@ public async Task GetUserIdentifiersAsync(UserKey userKey, HttpContext if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserIdentifiers.GetAdmin, resource: "users", resourceId: userKey.Value); - var result = await _users.GetIdentifiersByUserAsync(accessContext, ctx.RequestAborted); + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } + public async Task IdentifierExistsSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.WithinUser, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + + public async Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.TenantAny, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + public async Task AddUserIdentifierSelfAsync(HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 6bb41630..541b456d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -14,7 +14,7 @@ public interface IUserApplicationService Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); - Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default); + Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index ac665f8e..9b164d4a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -211,14 +211,25 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq #region Identifiers - public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) + public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) { var command = new GetUserIdentifiersCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); - var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenant, targetUserKey, innerCt); - return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + query ??= new UserIdentifierQuery(); + query.UserKey = targetUserKey; + + var result = await _identifierStore.QueryAsync(context.ResourceTenant, query, innerCt); + var dtoItems = result.Items.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + + return new PagedResult( + dtoItems, + result.TotalCount, + result.PageNumber, + result.PageSize, + result.SortBy, + result.Descending); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 2f0a7d72..18ab6a45 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -11,9 +11,10 @@ public interface IUserIdentifierStore Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); + Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); Task UpdateValueAsync(Guid id, string newRawValue, string newNormalizedValue, DateTimeOffset updatedAt, CancellationToken ct = default); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 425c8fc9..f6f52e60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -29,11 +29,11 @@ await service.AddUserIdentifierAsync(context, IsPrimary = true }); - var identifiers = await service.GetIdentifiersByUserAsync(context); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); - identifiers.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); + identifiers.Items.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); - identifiers.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary).Value.Should().Be("new@example.com"); + identifiers.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary).Value.Should().Be("new@example.com"); } [Fact] @@ -83,8 +83,8 @@ public async Task Adding_non_primary_should_not_affect_existing_primary() var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); - var before = await service.GetIdentifiersByUserAsync(context); - var existingPrimaryEmail = before.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + var before = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var existingPrimaryEmail = before.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); await service.AddUserIdentifierAsync(context, new AddUserIdentifierRequest @@ -94,9 +94,9 @@ await service.AddUserIdentifierAsync(context, IsPrimary = false }); - var after = await service.GetIdentifiersByUserAsync(context); + var after = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); - after.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + after.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) .Id.Should().Be(existingPrimaryEmail.Id); } @@ -108,8 +108,7 @@ public async Task Updating_value_should_reset_verification() var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UpdateSelf); - var email = (await service.GetIdentifiersByUserAsync(context)) - .Single(x => x.Type == UserIdentifierType.Email); + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email); await service.UpdateUserIdentifierAsync(context, new UpdateUserIdentifierRequest @@ -118,7 +117,7 @@ await service.UpdateUserIdentifierAsync(context, NewValue = "updated@example.com" }); - var updated = (await service.GetIdentifiersByUserAsync(context)) + var updated = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items .Single(x => x.Id == email.Id); updated.IsVerified.Should().BeFalse(); @@ -200,8 +199,7 @@ public async Task Unsetting_last_login_identifier_should_fail() var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UnsetPrimarySelf); - var email = (await service.GetIdentifiersByUserAsync(context)) - .Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); Func act = async () => await service.UnsetPrimaryUserIdentifierAsync(context, @@ -258,9 +256,9 @@ await service.AddUserIdentifierAsync(context, Value = "UserName" }); - var identifiers = await service.GetIdentifiersByUserAsync(context); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); - identifiers.Should().Contain(x => x.Value == "UserName"); + identifiers.Items.Should().Contain(x => x.Value == "UserName"); } [Fact] @@ -308,9 +306,9 @@ await service.AddUserIdentifierAsync(context, Value = "+90 (555) 123-45-67" }); - var identifiers = await service.GetIdentifiersByUserAsync(context); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); - identifiers.Should().Contain(x => + identifiers.Items.Should().Contain(x => x.Type == UserIdentifierType.Phone && x.Value == "+90 (555) 123-45-67"); } @@ -337,8 +335,8 @@ await service.AddUserIdentifierAsync(context, Value = "two@example.com" }); - var identifiers = await service.GetIdentifiersByUserAsync(context); - var second = identifiers.Single(x => x.Value == "two@example.com"); + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var second = identifiers.Items.Single(x => x.Value == "two@example.com"); Func act = async () => await service.UpdateUserIdentifierAsync(context, From b5450781ef0b743863d1cf58301940e7188e979a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 28 Feb 2026 15:14:04 +0300 Subject: [PATCH 05/29] Added Concurrency Support & Identifier Implementation --- .../Abstractions/Entity/IVersionedEntity.cs | 6 + .../Domain/Session/UAuthSession.cs | 61 +-- .../Domain/Session/UAuthSessionChain.cs | 28 +- .../Domain/Session/UAuthSessionRoot.cs | 27 +- .../Domain/Token/StoredRefreshToken.cs | 7 +- .../Runtime/UAuthConcurrencyException.cs | 14 + .../Domain/Role.cs | 5 +- .../Domain/PasswordCredential.cs | 33 +- .../Data/UAuthSessionDbContext.cs | 8 +- .../SessionChainProjection.cs | 2 +- .../EntityProjections/SessionProjection.cs | 2 +- .../SessionRootProjection.cs | 2 +- .../Mappers/SessionChainProjectionMapper.cs | 6 +- .../Mappers/SessionProjectionMapper.cs | 6 +- .../Mappers/SessionRootProjectionMapper.cs | 6 +- .../Projections/RefreshTokenProjection.cs | 2 +- .../Projections/RevokedIdTokenProjection.cs | 2 +- .../UAuthTokenDbContext.cs | 6 +- .../Dtos/UserIdentifierDto.cs | 7 +- .../InMemoryUserSeedContributor.cs | 32 +- .../Stores/InMemoryUserIdentifierStore.cs | 344 ++++++++--------- .../Domain/UserIdentifier.cs | 106 +++++- .../Domain/UserLifecycle.cs | 8 +- .../Domain/UserProfile.cs | 8 +- .../Mapping/UserIdentifierMapper.cs | 3 +- .../Services/UserApplicationService.cs | 38 +- .../Stores/IUserIdentifierStore.cs | 11 +- .../Users/IdentifierConcurrencyTests.cs | 346 ++++++++++++++++++ 28 files changed, 828 insertions(+), 298 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs new file mode 100644 index 00000000..be29b0ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IVersionedEntity +{ + long Version { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index bcb8b2a2..41a8c73c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSession +public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } public TenantKey Tenant { get; } @@ -17,21 +18,23 @@ public sealed class UAuthSession public DeviceContext Device { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } + public long Version { get; } private UAuthSession( - AuthSessionId sessionId, - TenantKey tenant, - UserKey userKey, - SessionChainId chainId, - DateTimeOffset createdAt, - DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, - bool isRevoked, - DateTimeOffset? revokedAt, - long securityVersionAtCreation, - DeviceContext device, - ClaimsSnapshot claims, - SessionMetadata metadata) + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceContext device, + ClaimsSnapshot claims, + SessionMetadata metadata, + long version) { SessionId = sessionId; Tenant = tenant; @@ -46,6 +49,7 @@ private UAuthSession( Device = device; Claims = claims; Metadata = metadata; + Version = version; } public static UAuthSession Create( @@ -72,13 +76,14 @@ public static UAuthSession Create( securityVersionAtCreation: 0, device: device, claims: claims ?? ClaimsSnapshot.Empty, - metadata: metadata + metadata: metadata, + version: 0 ); } - public UAuthSession WithSecurityVersion(long version) + public UAuthSession WithSecurityVersion(long securityVersion) { - if (SecurityVersionAtCreation == version) + if (SecurityVersionAtCreation == securityVersion) return this; return new UAuthSession( @@ -91,10 +96,11 @@ public UAuthSession WithSecurityVersion(long version) LastSeenAt, IsRevoked, RevokedAt, - version, + securityVersion, Device, Claims, - Metadata + Metadata, + Version + 1 ); } @@ -113,7 +119,8 @@ public UAuthSession Touch(DateTimeOffset at) SecurityVersionAtCreation, Device, Claims, - Metadata + Metadata, + Version + 1 ); } @@ -134,7 +141,8 @@ public UAuthSession Revoke(DateTimeOffset at) SecurityVersionAtCreation, Device, Claims, - Metadata + Metadata, + Version + 1 ); } @@ -151,7 +159,8 @@ internal static UAuthSession FromProjection( long securityVersionAtCreation, DeviceContext device, ClaimsSnapshot claims, - SessionMetadata metadata) + SessionMetadata metadata, + long version) { return new UAuthSession( sessionId, @@ -166,7 +175,8 @@ internal static UAuthSession FromProjection( securityVersionAtCreation, device, claims, - metadata + metadata, + version ); } @@ -202,7 +212,8 @@ public UAuthSession WithChain(SessionChainId chainId) securityVersionAtCreation: SecurityVersionAtCreation, device: Device, claims: Claims, - metadata: Metadata + metadata: Metadata, + version: Version + 1 ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index dfecbcb3..ed786129 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionChain +public sealed class UAuthSessionChain : IVersionedEntity { public SessionChainId ChainId { get; } public SessionRootId RootId { get; } @@ -14,6 +15,7 @@ public sealed class UAuthSessionChain public AuthSessionId? ActiveSessionId { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } + public long Version { get; } private UAuthSessionChain( SessionChainId chainId, @@ -25,7 +27,8 @@ private UAuthSessionChain( ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, bool isRevoked, - DateTimeOffset? revokedAt) + DateTimeOffset? revokedAt, + long version) { ChainId = chainId; RootId = rootId; @@ -37,6 +40,7 @@ private UAuthSessionChain( ActiveSessionId = activeSessionId; IsRevoked = isRevoked; RevokedAt = revokedAt; + Version = version; } public static UAuthSessionChain Create( @@ -57,7 +61,8 @@ public static UAuthSessionChain Create( claimsSnapshot: claimsSnapshot, activeSessionId: null, isRevoked: false, - revokedAt: null + revokedAt: null, + version: 0 ); } @@ -76,7 +81,8 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId) ClaimsSnapshot, activeSessionId: sessionId, isRevoked: false, - revokedAt: null + revokedAt: null, + version: Version + 1 ); } @@ -95,7 +101,8 @@ public UAuthSessionChain RotateSession(AuthSessionId sessionId) ClaimsSnapshot, activeSessionId: sessionId, isRevoked: false, - revokedAt: null + revokedAt: null, + version: Version + 1 ); } @@ -114,7 +121,8 @@ public UAuthSessionChain Revoke(DateTimeOffset at) ClaimsSnapshot, ActiveSessionId, isRevoked: true, - revokedAt: at + revokedAt: at, + version: Version + 1 ); } @@ -128,7 +136,8 @@ internal static UAuthSessionChain FromProjection( ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, bool isRevoked, - DateTimeOffset? revokedAt) + DateTimeOffset? revokedAt, + long version) { return new UAuthSessionChain( chainId, @@ -140,7 +149,8 @@ internal static UAuthSessionChain FromProjection( claimsSnapshot, activeSessionId, isRevoked, - revokedAt + revokedAt, + version ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 3eb85942..6437991a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -1,8 +1,9 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; -public sealed class UAuthSessionRoot +public sealed class UAuthSessionRoot : IVersionedEntity { public SessionRootId RootId { get; } public UserKey UserKey { get; } @@ -12,6 +13,7 @@ public sealed class UAuthSessionRoot public long SecurityVersion { get; } public IReadOnlyList Chains { get; } public DateTimeOffset LastUpdatedAt { get; } + public long Version { get; } private UAuthSessionRoot( SessionRootId rootId, @@ -21,7 +23,8 @@ private UAuthSessionRoot( DateTimeOffset? revokedAt, long securityVersion, IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + DateTimeOffset lastUpdatedAt, + long version) { RootId = rootId; Tenant = tenant; @@ -31,6 +34,7 @@ private UAuthSessionRoot( SecurityVersion = securityVersion; Chains = chains; LastUpdatedAt = lastUpdatedAt; + Version = version; } public static UAuthSessionRoot Create( @@ -46,7 +50,8 @@ public static UAuthSessionRoot Create( revokedAt: null, securityVersion: 0, chains: Array.Empty(), - lastUpdatedAt: issuedAt + lastUpdatedAt: issuedAt, + version: 0 ); } @@ -63,7 +68,8 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) revokedAt: at, securityVersion: SecurityVersion, chains: Chains, - lastUpdatedAt: at + lastUpdatedAt: at, + version: Version + 1 ); } @@ -80,7 +86,8 @@ public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) RevokedAt, SecurityVersion, Chains.Concat(new[] { chain }).ToArray(), - at + at, + Version + 1 ); } @@ -92,7 +99,8 @@ internal static UAuthSessionRoot FromProjection( DateTimeOffset? revokedAt, long securityVersion, IReadOnlyList chains, - DateTimeOffset lastUpdatedAt) + DateTimeOffset lastUpdatedAt, + long version) { return new UAuthSessionRoot( rootId, @@ -102,9 +110,8 @@ internal static UAuthSessionRoot FromProjection( revokedAt, securityVersion, chains, - lastUpdatedAt + lastUpdatedAt, + version ); } - - } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index 31cb67eb..306ef666 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.ComponentModel.DataAnnotations.Schema; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -7,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; /// Represents a persisted refresh token bound to a session. /// Stored as a hashed value for security reasons. /// -public sealed record StoredRefreshToken +public sealed record StoredRefreshToken : IVersionedEntity { public string TokenHash { get; init; } = default!; @@ -24,6 +25,8 @@ public sealed record StoredRefreshToken public string? ReplacedByTokenHash { get; init; } + public long Version { get; } + [NotMapped] public bool IsRevoked => RevokedAt.HasValue; diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs new file mode 100644 index 00000000..c5d03c9d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthConcurrencyException : UAuthRuntimeException +{ + public override int StatusCode => 409; + + public override string Title => "The resource was modified by another process."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/concurrency"; + + public UAuthConcurrencyException(string code = "concurrency_conflict") : base(code, code) + { + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs index 2550b229..60c76771 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs @@ -1,7 +1,10 @@ using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; -public sealed class Role +public sealed class Role : IVersionedEntity { public required string Name { get; init; } public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); + + public long Version { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 702eb1d5..0f63cce1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,10 +1,11 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor +public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity { public Guid Id { get; init; } public TenantKey Tenant { get; init; } @@ -19,8 +20,10 @@ public sealed class PasswordCredential : ISecretCredential, ICredentialDescripto public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; private set; } + public long Version { get; private set; } public bool IsRevoked => Security.RevokedAt is not null; + public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; public PasswordCredential( @@ -41,6 +44,28 @@ public PasswordCredential( Metadata = metadata; CreatedAt = createdAt; UpdatedAt = updatedAt; + Version = 0; + } + + public static PasswordCredential Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt) + { + return new( + id ?? Guid.NewGuid(), + tenant, + userKey, + secretHash, + security, + metadata, + createdAt, + updatedAt); } public void ChangeSecret(string newSecretHash, DateTimeOffset now) @@ -54,18 +79,21 @@ public void ChangeSecret(string newSecretHash, DateTimeOffset now) SecretHash = newSecretHash; UpdatedAt = now; Security = Security.RotateStamp(); + Version++; } public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) { Security = Security.SetExpiry(expiresAt); UpdatedAt = now; + Version++; } public void UpdateSecurity(CredentialSecurityState security, DateTimeOffset now) { Security = security; UpdatedAt = now; + Version++; } public void Revoke(DateTimeOffset now) @@ -74,5 +102,6 @@ public void Revoke(DateTimeOffset now) return; Security = Security.Revoke(now); UpdatedAt = now; + Version++; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index 16666c7b..22a3e1fb 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -33,8 +33,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); @@ -62,8 +61,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.UserKey) .IsRequired(); @@ -90,7 +88,7 @@ protected override void OnModelCreating(ModelBuilder b) b.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion).IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index 20358cf6..ff2032a6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -23,5 +23,5 @@ internal sealed class SessionChainProjection public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index fdf642ed..58c37604 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -26,5 +26,5 @@ internal sealed class SessionProjection public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index 05be286e..af1b2aac 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -16,5 +16,5 @@ internal sealed class SessionRootProjection public long SecurityVersion { get; set; } public DateTimeOffset LastUpdatedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 93c582f4..cb1494ea 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -16,7 +16,8 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) p.ClaimsSnapshot, p.ActiveSessionId, p.IsRevoked, - p.RevokedAt + p.RevokedAt, + p.Version ); } @@ -35,7 +36,8 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) ActiveSessionId = chain.ActiveSessionId, IsRevoked = chain.IsRevoked, - RevokedAt = chain.RevokedAt + RevokedAt = chain.RevokedAt, + Version = chain.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index da2a7fe8..688bb9e0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -19,7 +19,8 @@ public static UAuthSession ToDomain(this SessionProjection p) p.SecurityVersionAtCreation, p.Device, p.Claims, - p.Metadata + p.Metadata, + p.Version ); } @@ -42,7 +43,8 @@ public static SessionProjection ToProjection(this UAuthSession s) SecurityVersionAtCreation = s.SecurityVersionAtCreation, Device = s.Device, Claims = s.Claims, - Metadata = s.Metadata + Metadata = s.Metadata, + Version = s.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index a0c223ae..f0e2627e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -14,7 +14,8 @@ public static UAuthSessionRoot ToDomain(this SessionRootProjection root, IReadOn root.RevokedAt, root.SecurityVersion, chains ?? Array.Empty(), - root.LastUpdatedAt + root.LastUpdatedAt, + root.Version ); } @@ -30,7 +31,8 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, - LastUpdatedAt = root.LastUpdatedAt + LastUpdatedAt = root.LastUpdatedAt, + Version = root.Version }; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index 138f800d..d1167f36 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -20,5 +20,5 @@ internal sealed class RefreshTokenProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs index 0dc5a1e5..72418bec 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -12,5 +12,5 @@ internal sealed class RevokedTokenIdProjection public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset RevokedAt { get; set; } - public byte[] RowVersion { get; set; } = default!; + public long Version { get; set; } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs index be84d6e1..f8c371ea 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -18,8 +18,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.TokenHash) .IsRequired(); @@ -40,8 +39,7 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.RowVersion) - .IsRowVersion(); + e.Property(x => x.Version).IsConcurrencyToken(); e.Property(x => x.Jti) .IsRequired(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs index a5b05ee5..e8fc7fa7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -1,6 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; -public sealed record UserIdentifierDto +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierDto : IVersionedEntity { public Guid Id { get; set; } public required UserIdentifierType Type { get; set; } @@ -10,4 +12,5 @@ public sealed record UserIdentifierDto public bool IsVerified { get; set; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? VerifiedAt { get; set; } + public long Version { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index bc7b2bf5..6666c55d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -64,23 +64,21 @@ await _profiles.CreateAsync(tenant, CreatedAt = _clock.UtcNow }, ct); - var usernameIdentifier = new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = username, - NormalizedValue = _identifierNormalizer - .Normalize(UserIdentifierType.Username, username) - .Normalized, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }; - - await _identifiers.CreateAsync(tenant, usernameIdentifier, ct); - await _identifiers.SetPrimaryAsync(usernameIdentifier.Id, _clock.UtcNow, ct); + await _identifiers.CreateAsync(tenant, + new UserIdentifier + { + Id = Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = username, + NormalizedValue = _identifierNormalizer + .Normalize(UserIdentifierType.Username, username) + .Normalized, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); await _identifiers.CreateAsync(tenant, new UserIdentifier diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index bcc21c1f..b6e375f4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -10,282 +10,260 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { private readonly Dictionary _store = new(); + private readonly object _lock = new(); public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + lock (_lock) + { + ct.ThrowIfCancellationRequested(); - var candidates = _store.Values - .Where(x => - x.Tenant == query.Tenant && - x.Type == query.Type && - x.NormalizedValue == query.NormalizedValue && - !x.IsDeleted); + var candidates = _store.Values + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); - if (query.ExcludeIdentifierId.HasValue) - candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + if (query.ExcludeIdentifierId.HasValue) + candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); - candidates = query.Scope switch - { - IdentifierExistenceScope.WithinUser => candidates.Where(x => x.UserKey == query.UserKey), - IdentifierExistenceScope.TenantPrimaryOnly => candidates.Where(x => x.IsPrimary), - IdentifierExistenceScope.TenantAny => candidates, - _ => candidates - }; + candidates = query.Scope switch + { + IdentifierExistenceScope.WithinUser => candidates.Where(x => x.UserKey == query.UserKey), + IdentifierExistenceScope.TenantPrimaryOnly => candidates.Where(x => x.IsPrimary), + IdentifierExistenceScope.TenantAny => candidates, + _ => candidates + }; - var match = candidates.FirstOrDefault(); + var match = candidates.FirstOrDefault(); - if (match is null) - return Task.FromResult(new IdentifierExistenceResult(false)); + if (match is null) + return Task.FromResult(new IdentifierExistenceResult(false)); - return Task.FromResult(new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); + return Task.FromResult(new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); + } } public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var identifier = _store.Values.FirstOrDefault(x => + lock (_lock) + { + var identifier = _store.Values.FirstOrDefault(x => x.Tenant == tenant && x.Type == type && x.NormalizedValue == normalizedValue && !x.IsDeleted); - return Task.FromResult(identifier); + return Task.FromResult(identifier?.Cloned()); + } } public Task GetByIdAsync(Guid id, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier)) - return Task.FromResult(null); + lock (_lock) + { + if (!_store.TryGetValue(id, out var identifier)) + return Task.FromResult(null); - if (identifier.IsDeleted) - return Task.FromResult(null); + if (identifier.IsDeleted) + return Task.FromResult(null); - return Task.FromResult(identifier); + return Task.FromResult(identifier.Cloned()); + } } public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = _store.Values - .Where(x => x.Tenant == tenant) - .Where(x => x.UserKey == userKey) - .Where(x => !x.IsDeleted) - .OrderBy(x => x.CreatedAt) - .ToList() - .AsReadOnly(); - - return Task.FromResult>(result); + lock (_lock) + { + var result = _store.Values + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderBy(x => x.CreatedAt) + .Select(x => x.Cloned()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } } public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + lock (_lock) + { + ct.ThrowIfCancellationRequested(); - if (query.UserKey is null) - throw new UAuthIdentifierValidationException("userKey_required"); + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); - var normalized = query.Normalize(); + var normalized = query.Normalize(); - var baseQuery = _store.Values - .Where(x => x.Tenant == tenant) - .Where(x => x.UserKey == query.UserKey.Value); + var baseQuery = _store.Values + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == query.UserKey.Value); - if (!query.IncludeDeleted) - baseQuery = baseQuery.Where(x => !x.IsDeleted); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - baseQuery = query.SortBy switch - { - nameof(UserIdentifier.Type) => - query.Descending ? baseQuery.OrderByDescending(x => x.Type) - : baseQuery.OrderBy(x => x.Type), - - nameof(UserIdentifier.CreatedAt) => - query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) - : baseQuery.OrderBy(x => x.CreatedAt), - - _ => baseQuery.OrderBy(x => x.CreatedAt) - }; - - var totalCount = baseQuery.Count(); - - var items = baseQuery - .Skip((normalized.PageNumber - 1) * normalized.PageSize) - .Take(normalized.PageSize) - .ToList() - .AsReadOnly(); - - return Task.FromResult( - new PagedResult( - items, - totalCount, - normalized.PageNumber, - normalized.PageSize, - query.SortBy, - query.Descending)); + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => + query.Descending ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Cloned()) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } } - public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) + public Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (identifier.Id == Guid.Empty) - identifier.Id = Guid.NewGuid(); + lock (_lock) + { + if (!_store.TryGetValue(entity.Id, out var existing)) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); - identifier.Tenant = tenant; + if (existing.Version != expectedVersion) + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); - if (identifier.IsPrimary) - { - foreach (var existing in _store.Values.Where(x => - x.Tenant == tenant && - x.UserKey == identifier.UserKey && - x.Type == identifier.Type && - x.IsPrimary && - !x.IsDeleted)) + if (entity.IsPrimary) { - existing.IsPrimary = false; - existing.UpdatedAt = identifier.CreatedAt; + foreach (var other in _store.Values.Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } } - } - _store[identifier.Id] = identifier; + _store[entity.Id] = entity.Cloned(); + } return Task.CompletedTask; } - public Task UpdateValueAsync(Guid id, string newRawValue, string newNormalizedValue, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); - - if (identifier.NormalizedValue == newNormalizedValue) - throw new UAuthIdentifierConflictException("identifier_value_unchanged"); - - identifier.Value = newRawValue; - identifier.NormalizedValue = newNormalizedValue; + lock (_lock) + { + if (identifier.Id == Guid.Empty) + identifier.Id = Guid.NewGuid(); - identifier.IsVerified = false; - identifier.VerifiedAt = null; - identifier.UpdatedAt = updatedAt; + identifier.Tenant = tenant; - return Task.CompletedTask; - } - - public Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); - - if (identifier.IsVerified) - throw new UAuthIdentifierConflictException("identifier_already_verified"); + if (identifier.IsPrimary) + { + foreach (var existing in _store.Values.Where(x => + x.Tenant == tenant && + x.UserKey == identifier.UserKey && + x.Type == identifier.Type && + x.IsPrimary && + !x.IsDeleted)) + { + existing.IsPrimary = false; + existing.UpdatedAt = identifier.CreatedAt; + } + } - identifier.IsVerified = true; - identifier.VerifiedAt = verifiedAt; - identifier.UpdatedAt = verifiedAt; + identifier.Version = 0; + _store[identifier.Id] = identifier; + } return Task.CompletedTask; } - public Task SetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default) + public Task DeleteAsync(UserIdentifier entity, long expectedVersion, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var target) || target.IsDeleted) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); - - foreach (var idf in _store.Values.Where(x => - x.Tenant == target.Tenant && - x.UserKey == target.UserKey && - x.Type == target.Type && - x.IsPrimary && - !x.IsDeleted)) + lock (_lock) { - if (idf.Id == target.Id) - continue; - idf.IsPrimary = false; - idf.UpdatedAt = updatedAt; - } + if (!_store.TryGetValue(entity.Id, out var identifier)) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); - target.IsPrimary = true; - target.UpdatedAt = updatedAt; + if (identifier.Version != expectedVersion) + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); - return Task.CompletedTask; - } - - public Task UnsetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!_store.TryGetValue(id, out var identifier) || identifier.IsDeleted) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); + if (mode == DeleteMode.Hard) + { + _store.Remove(entity.Id); + return Task.CompletedTask; + } - if (!identifier.IsPrimary) - { - throw new UAuthIdentifierConflictException("identifier_is_not_primary_already"); + _store[entity.Id] = entity.Cloned(); } - - identifier.IsPrimary = false; - identifier.UpdatedAt = updatedAt; - return Task.CompletedTask; } - public Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_store.TryGetValue(id, out var identifier)) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); + List snapshot; - if (mode == DeleteMode.Hard) + lock (_lock) { - _store.Remove(id); - return Task.CompletedTask; + snapshot = _store.Values + .Where(x => x.Tenant == tenant && x.UserKey == userKey) + .Select(x => x.Cloned()) + .ToList(); } - if (identifier.IsDeleted) - throw new UAuthIdentifierConflictException("identifier_already_deleted"); - - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - identifier.UpdatedAt = deletedAt; - - return Task.CompletedTask; - } - - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var identifiers = _store.Values.Where(x => x.Tenant == tenant && x.UserKey == userKey).ToList(); - - foreach (var identifier in identifiers) + foreach (var identifier in snapshot) { if (mode == DeleteMode.Hard) { - _store.Remove(identifier.Id); + lock (_lock) + { + _store.Remove(identifier.Id); + } } else { - if (identifier.IsDeleted) - continue; - - identifier.IsDeleted = true; - identifier.DeletedAt = deletedAt; - identifier.IsPrimary = false; - identifier.UpdatedAt = deletedAt; + var expected = identifier.Version; + identifier.SoftDelete(deletedAt); + await SaveAsync(identifier, expected, ct); } } - - return Task.CompletedTask; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index a546a018..ec9951a1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -1,11 +1,11 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; - -// TODO: Add concurrency property -public sealed record UserIdentifier +public sealed record UserIdentifier : IVersionedEntity { public Guid Id { get; set; } public TenantKey Tenant { get; set; } @@ -25,4 +25,102 @@ public sealed record UserIdentifier public DateTimeOffset? VerifiedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } + + + public UserIdentifier Cloned() + { + return new UserIdentifier + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + IsPrimary = IsPrimary, + IsVerified = IsVerified, + IsDeleted = IsDeleted, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + VerifiedAt = VerifiedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public void ChangeValue(string newRawValue, string newNormalizedValue, DateTimeOffset now) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (NormalizedValue == newNormalizedValue) + throw new UAuthIdentifierConflictException("identifier_value_unchanged"); + + Value = newRawValue; + NormalizedValue = newNormalizedValue; + + IsVerified = false; + VerifiedAt = null; + UpdatedAt = now; + + Version++; + } + + public void MarkVerified(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsVerified) + throw new UAuthIdentifierConflictException("identifier_already_verified"); + + IsVerified = true; + VerifiedAt = at; + UpdatedAt = at; + + Version++; + } + + public void SetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsPrimary) + return; + + IsPrimary = true; + UpdatedAt = at; + + Version++; + } + + public void UnsetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (!IsPrimary) + throw new UAuthIdentifierConflictException("identifier_is_not_primary_already"); + + IsPrimary = false; + UpdatedAt = at; + + Version++; + } + + public void SoftDelete(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierConflictException("identifier_already_deleted"); + + IsDeleted = true; + DeletedAt = at; + IsPrimary = false; + UpdatedAt = at; + + Version++; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 0337a401..670922b7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,11 +1,11 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -// TODO: Add concurrency property -public sealed record class UserLifecycle +public sealed record class UserLifecycle : IVersionedEntity { public TenantKey Tenant { get; set; } @@ -19,4 +19,6 @@ public sealed record class UserLifecycle public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 543230e8..60a84aad 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,11 +1,11 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; // TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) -// TODO: Add concurrency property -public sealed record class UserProfile +public sealed record class UserProfile : IVersionedEntity { public TenantKey Tenant { get; set; } @@ -30,4 +30,6 @@ public sealed record class UserProfile public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index b37a3954..f3ad993f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -14,6 +14,7 @@ public static UserIdentifierDto ToDto(UserIdentifier record) IsPrimary = record.IsPrimary, IsVerified = record.IsVerified, CreatedAt = record.CreatedAt, - VerifiedAt = record.VerifiedAt + VerifiedAt = record.VerifiedAt, + Version = record.Version }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 9b164d4a..acc38777 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -390,7 +390,10 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde throw new UAuthIdentifierConflictException("identifier_already_exists"); } - await _identifierStore.UpdateValueAsync(identifier.Id, request.NewValue, normalized.Normalized, _clock.UtcNow, innerCt); + var expectedVersion = identifier.Version; + identifier.ChangeValue(request.NewValue, normalized.Normalized, _clock.UtcNow); + + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -417,7 +420,9 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar if (result.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists"); - await _identifierStore.SetPrimaryAsync(request.IdentifierId, _clock.UtcNow, innerCt); + var expectedVersion = identifier.Version; + identifier.SetPrimary(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -434,7 +439,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!identifier.IsPrimary) - throw new UAuthIdentifierValidationException("identifier_not_primary"); + throw new UAuthIdentifierValidationException("identifier_already_not_primary"); var userIdentifiers = await _identifierStore.GetByUserAsync(identifier.Tenant, identifier.UserKey, innerCt); @@ -452,7 +457,9 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); } - await _identifierStore.UnsetPrimaryAsync(request.IdentifierId, _clock.UtcNow, innerCt); + var expectedVersion = identifier.Version; + identifier.UnsetPrimary(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -463,7 +470,14 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde var command = new VerifyUserIdentifierCommand(async innerCt => { EnsureOverrideAllowed(context); - await _identifierStore.MarkVerifiedAsync(request.IdentifierId, _clock.UtcNow, innerCt); + + var identifier = await _identifierStore.GetByIdAsync(request.IdentifierId, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + var expectedVersion = identifier.Version; + identifier.MarkVerified(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -496,9 +510,19 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde } if (IsLoginIdentifier(identifier.Type) && loginIdentifiers.Count == 1) - throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); - await _identifierStore.DeleteAsync(request.IdentifierId, request.Mode, _clock.UtcNow, innerCt); + var expectedVersion = identifier.Version; + + if (request.Mode == DeleteMode.Hard) + { + await _identifierStore.DeleteAsync(identifier, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); + } + else + { + identifier.SoftDelete(_clock.UtcNow); + await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + } }); await _accessOrchestrator.ExecuteAsync(context, command, ct); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 18ab6a45..58d18214 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -14,18 +14,11 @@ public interface IUserIdentifierStore Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); + Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default); Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - Task UpdateValueAsync(Guid id, string newRawValue, string newNormalizedValue, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task MarkVerifiedAsync(Guid id, DateTimeOffset verifiedAt, CancellationToken ct = default); - - Task SetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task UnsetPrimaryAsync(Guid id, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(Guid id, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + Task DeleteAsync(UserIdentifier entity, long expectedVersion, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs new file mode 100644 index 00000000..5a6f06eb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -0,0 +1,346 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class IdentifierConcurrencyTests +{ + [Fact] + public async Task Save_should_increment_version() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = TenantKey.Single, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + await store.CreateAsync(TenantKey.Single, identifier); + + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + copy.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy, expected); + + var updated = await store.GetByIdAsync(id); + + Assert.Equal(expected + 1, updated!.Version); + } + + [Fact] + public async Task Delete_should_throw_when_version_conflicts() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = TenantKey.Single, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + await store.CreateAsync(TenantKey.Single, identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.SoftDelete(now); + await store.SaveAsync(copy1, expected1); + + await Assert.ThrowsAsync(async () => + { + await store.DeleteAsync(copy2!, copy2!.Version, DeleteMode.Soft, now); + }); + } + + [Fact] + public async Task Parallel_SetPrimary_should_conflict_deterministic() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = TenantKey.Single, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + await store.CreateAsync(TenantKey.Single, identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = Enumerable.Range(0, 2) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } + + [Fact] + public async Task Update_should_throw_concurrency_when_versions_conflict() + { + var store = new InMemoryUserIdentifierStore(); + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var identifier = new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + await store.CreateAsync(tenant, identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy1, expected1); + + var expected2 = copy2!.Version; + copy2.ChangeValue("c@test.com", "c@test.com", now); + + await Assert.ThrowsAsync(async () => + { + await store.SaveAsync(copy2, expected2); + }); + } + + [Fact] + public async Task Parallel_updates_should_result_in_single_success() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + await store.CreateAsync(tenant, identifier); + + int success = 0; + int conflicts = 0; + + var task1 = Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + copy.ChangeValue("x@test.com", "x@test.com", now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }); + + var task2 = Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + copy.ChangeValue("y@test.com", "y@test.com", now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }); + + await Task.WhenAll(task1, task2); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + } + + [Fact] + public async Task High_contention_updates_should_allow_only_one_success() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "initial@test.com", + NormalizedValue = "initial@test.com", + CreatedAt = now + }; + + await store.CreateAsync(tenant, identifier); + + int success = 0; + int conflicts = 0; + + var tasks = Enumerable.Range(0, 20) + .Select(i => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + var newValue = $"user{i}@test.com"; + + copy.ChangeValue(newValue, newValue, now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.True(success >= 1); + Assert.Equal(20, success + conflicts); + + var final = await store.GetByIdAsync(id); + Assert.Equal(success, final!.Version); + } + + [Fact] + public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var id = Guid.NewGuid(); + + var identifier = new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = TestUsers.Admin, + Type = UserIdentifierType.Email, + Value = "primary@test.com", + NormalizedValue = "primary@test.com", + CreatedAt = now + }; + + await store.CreateAsync(tenant, identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(20); + + var tasks = Enumerable.Range(0, 20) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(19, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } +} From a395d7254e492e60b410dc8f5094e08d7a00339b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 28 Feb 2026 15:26:06 +0300 Subject: [PATCH 06/29] Add & Fix Test --- .../Users/IdentifierConcurrencyTests.cs | 165 ++++++++++++++---- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 5a6f06eb..320d7a08 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -168,7 +168,7 @@ await Assert.ThrowsAsync(async () => } [Fact] - public async Task Parallel_updates_should_result_in_single_success() + public async Task Parallel_updates_should_result_in_single_success_deterministic() { var store = new InMemoryUserIdentifierStore(); var now = DateTimeOffset.UtcNow; @@ -191,42 +191,46 @@ public async Task Parallel_updates_should_result_in_single_success() int success = 0; int conflicts = 0; - var task1 = Task.Run(async () => - { - try - { - var copy = await store.GetByIdAsync(id); - var expected = copy!.Version; - copy.ChangeValue("x@test.com", "x@test.com", now); - await store.SaveAsync(copy, expected); - Interlocked.Increment(ref success); - } - catch (UAuthConcurrencyException) - { - Interlocked.Increment(ref conflicts); - } - }); + var barrier = new Barrier(2); - var task2 = Task.Run(async () => - { - try - { - var copy = await store.GetByIdAsync(id); - var expected = copy!.Version; - copy.ChangeValue("y@test.com", "y@test.com", now); - await store.SaveAsync(copy, expected); - Interlocked.Increment(ref success); - } - catch (UAuthConcurrencyException) + var tasks = Enumerable.Range(0, 2) + .Select(i => Task.Run(async () => { - Interlocked.Increment(ref conflicts); - } - }); + try + { + var copy = await store.GetByIdAsync(id); - await Task.WhenAll(task1, task2); + // İki thread de burada bekler + barrier.SignalAndWait(); + + var expected = copy!.Version; + + var newValue = i == 0 + ? "x@test.com" + : "y@test.com"; + + copy.ChangeValue(newValue, newValue, now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); Assert.Equal(1, success); Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.NotNull(final); + Assert.Equal(1, final!.Version); } [Fact] @@ -343,4 +347,103 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic Assert.True(final!.IsPrimary); Assert.Equal(1, final.Version); } + + [Fact] + public async Task Two_identifiers_racing_for_primary_should_allow() + { + var store = new InMemoryUserIdentifierStore(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var user = TestUsers.Admin; + + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var identifier1 = new UserIdentifier + { + Id = id1, + Tenant = tenant, + UserKey = user, + Type = UserIdentifierType.Email, + Value = "a@test.com", + NormalizedValue = "a@test.com", + CreatedAt = now + }; + + var identifier2 = new UserIdentifier + { + Id = id2, + Tenant = tenant, + UserKey = user, + Type = UserIdentifierType.Email, + Value = "b@test.com", + NormalizedValue = "b@test.com", + CreatedAt = now + }; + + await store.CreateAsync(tenant, identifier1); + await store.CreateAsync(tenant, identifier2); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = new[] + { + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id1); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }), + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id2); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }) + }; + + await Task.WhenAll(tasks); + + Assert.Equal(2, success); + Assert.Equal(0, conflicts); + + var all = await store.GetByUserAsync(tenant, user); + + var primaries = all + .Where(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .ToList(); + + Assert.Single(primaries); + } } From 079181f53b07c098b60e4f0dc1168c70bf30f23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 28 Feb 2026 18:49:33 +0300 Subject: [PATCH 07/29] Added Session Concurrency --- .../Abstractions/Issuers/ISessionIssuer.cs | 2 +- ...SessionStoreKernel.cs => ISessionStore.cs} | 21 +- ...rnelFactory.cs => ISessionStoreFactory.cs} | 8 +- .../Domain/Session/UAuthSession.cs | 26 +-- .../Domain/Session/UAuthSessionChain.cs | 4 +- .../Domain/Session/UAuthSessionRoot.cs | 37 +++- .../Errors/Runtime/UAuthNotFoundException.cs | 14 ++ .../Errors/UAuthNotFoundException.cs | 8 - .../UltimateAuthServerBuilderValidation.cs | 2 +- .../Flows/Refresh/SessionTouchService.cs | 9 +- .../Issuers/UAuthSessionIssuer.cs | 197 +++++++++++++----- .../Orchestrator/CreateLoginSessionCommand.cs | 2 +- .../Infrastructure/User/UAuthUserAccessor.cs | 4 +- .../Services/UAuthSessionQueryService.cs | 6 +- .../Services/UAuthSessionValidator.cs | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- ...onStoreKernel.cs => EfCoreSessionStore.cs} | 104 ++++++--- ...actory.cs => EfCoreSessionStoreFactory.cs} | 8 +- ...StoreKernel.cs => InMemorySessionStore.cs} | 109 ++++++++-- ...tory.cs => InMemorySessionStoreFactory.cs} | 8 +- .../ServiceCollectionExtensions.cs | 2 +- .../Core/UAuthSessionTests.cs | 2 + 22 files changed, 396 insertions(+), 185 deletions(-) rename src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/{ISessionStoreKernel.cs => ISessionStore.cs} (54%) rename src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/{ISessionStoreKernelFactory.cs => ISessionStoreFactory.cs} (66%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/{EfCoreSessionStoreKernel.cs => EfCoreSessionStore.cs} (70%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/{EfCoreSessionStoreKernelFactory.cs => EfCoreSessionStoreFactory.cs} (70%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/{InMemorySessionStoreKernel.cs => InMemorySessionStore.cs} (52%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/{InMemorySessionStoreKernelFactory.cs => InMemorySessionStoreFactory.cs} (63%) diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index f78e4806..b427fb04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface ISessionIssuer { - Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default); Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs similarity index 54% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 09e130c8..14775660 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -2,25 +2,28 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; -public interface ISessionStoreKernel +public interface ISessionStore { Task ExecuteAsync(Func action, CancellationToken ct = default); Task ExecuteAsync(Func> action, CancellationToken ct = default); Task GetSessionAsync(AuthSessionId sessionId); - Task SaveSessionAsync(UAuthSession session); + Task SaveSessionAsync(UAuthSession session, long expectedVersion); + Task CreateSessionAsync(UAuthSession session); Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); Task GetChainAsync(SessionChainId chainId); - Task SaveChainAsync(UAuthSessionChain chain); + Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion); + Task CreateChainAsync(UAuthSessionChain chain); Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - Task GetActiveSessionIdAsync(SessionChainId chainId); - Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); + //Task GetActiveSessionIdAsync(SessionChainId chainId); + //Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); - Task GetSessionRootByUserAsync(UserKey userKey); - Task GetSessionRootByIdAsync(SessionRootId rootId); - Task SaveSessionRootAsync(UAuthSessionRoot root); - Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at); + Task GetRootByUserAsync(UserKey userKey); + Task GetRootByIdAsync(SessionRootId rootId); + Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion); + Task CreateRootAsync(UAuthSessionRoot root); + Task RevokeRootAsync(UserKey userKey, DateTimeOffset at); Task GetChainIdBySessionAsync(AuthSessionId sessionId); Task> GetChainsByUserAsync(UserKey userKey); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs similarity index 66% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs index 936731e2..72cd6b3e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernelFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; /// /// Provides a factory abstraction for creating tenant-scoped session store /// instances capable of persisting sessions, chains, and session roots. -/// Implementations typically resolve concrete types from the dependency injection container. +/// Implementations typically resolve concrete types from the dependency injection container. /// -public interface ISessionStoreKernelFactory +public interface ISessionStoreFactory { /// /// Creates and returns a session store instance for the specified user ID type within the given tenant context. /// /// - /// An implementation able to perform session persistence operations. + /// An implementation able to perform session persistence operations. /// - ISessionStoreKernel Create(TenantKey tenant); + ISessionStore Create(TenantKey tenant); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 41a8c73c..ddacbbc1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -59,6 +59,7 @@ public static UAuthSession Create( SessionChainId chainId, DateTimeOffset now, DateTimeOffset expiresAt, + long securityVersion, DeviceContext device, ClaimsSnapshot? claims, SessionMetadata metadata) @@ -73,7 +74,7 @@ public static UAuthSession Create( lastSeenAt: now, isRevoked: false, revokedAt: null, - securityVersionAtCreation: 0, + securityVersionAtCreation: securityVersion, device: device, claims: claims ?? ClaimsSnapshot.Empty, metadata: metadata, @@ -81,29 +82,6 @@ public static UAuthSession Create( ); } - public UAuthSession WithSecurityVersion(long securityVersion) - { - if (SecurityVersionAtCreation == securityVersion) - return this; - - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - LastSeenAt, - IsRevoked, - RevokedAt, - securityVersion, - Device, - Claims, - Metadata, - Version + 1 - ); - } - public UAuthSession Touch(DateTimeOffset at) { return new UAuthSession( diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index ed786129..5e16c30f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -80,8 +80,8 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId) SecurityVersionAtCreation, ClaimsSnapshot, activeSessionId: sessionId, - isRevoked: false, - revokedAt: null, + isRevoked: IsRevoked, + revokedAt: RevokedAt, version: Version + 1 ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 6437991a..e3279415 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -46,12 +46,27 @@ public static UAuthSessionRoot Create( SessionRootId.New(), tenant, userKey, - isRevoked: false, - revokedAt: null, - securityVersion: 0, + false, + null, + 0, chains: Array.Empty(), - lastUpdatedAt: issuedAt, - version: 0 + issuedAt, + 0 + ); + } + + public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) + { + return new UAuthSessionRoot( + RootId, + Tenant, + UserKey, + IsRevoked, + RevokedAt, + SecurityVersion + 1, + Chains, + at, + Version + 1 ); } @@ -64,12 +79,12 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) RootId, Tenant, UserKey, - isRevoked: true, - revokedAt: at, - securityVersion: SecurityVersion, - chains: Chains, - lastUpdatedAt: at, - version: Version + 1 + true, + at, + SecurityVersion + 1, + Chains, + at, + Version + 1 ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs new file mode 100644 index 00000000..cc2468a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public class UAuthNotFoundException : UAuthRuntimeException +{ + public override int StatusCode => 400; + + public override string Title => "The resource is not found."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/notfound"; + + public UAuthNotFoundException(string code = "resource_not_found") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs deleted file mode 100644 index 7adef0fa..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthNotFoundException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthNotFoundException : UAuthRuntimeException -{ - public UAuthNotFoundException(string code) : base(code, "Resource not found.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs index db88f962..8370bce1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -15,7 +15,7 @@ public static IServiceCollection Build(this UltimateAuthServerBuilder builder) //if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>)))) // throw new InvalidOperationException("No credential store registered."); - if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStoreKernel)))) + if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore)))) throw new InvalidOperationException("No session store registered."); return services; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index 5df517b3..a2ea6223 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -5,9 +5,9 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public sealed class SessionTouchService : ISessionTouchService { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; - public SessionTouchService(ISessionStoreKernelFactory kernelFactory) + public SessionTouchService(ISessionStoreFactory kernelFactory) { _kernelFactory = kernelFactory; } @@ -29,14 +29,17 @@ public async Task RefreshAsync(SessionValidationResult val await kernel.ExecuteAsync(async _ => { var session = await kernel.GetSessionAsync(validation.SessionId.Value); + if (session is null || session.IsRevoked) return; if (sessionTouchMode == SessionTouchMode.IfNeeded && now - session.LastSeenAt < policy.TouchInterval.Value) return; + var expectedVersion = session.Version; var touched = session.Touch(now); - await kernel.SaveSessionAsync(touched); + + await kernel.SaveSessionAsync(touched, expectedVersion); didTouch = true; }, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 4975109f..cf4a5a55 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -11,18 +11,18 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthSessionIssuer : ISessionIssuer { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(ISessionStoreKernelFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(ISessionStoreFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { _kernelFactory = kernelFactory; _opaqueGenerator = opaqueGenerator; _options = options.Value; } - public async Task IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) + public async Task IssueSessionAsync(AuthenticatedSessionContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (context.Mode == UAuthMode.PureJwt) @@ -44,41 +44,50 @@ public async Task IssueLoginSessionAsync(AuthenticatedSessionCont expiresAt = absoluteExpiry; } - var session = UAuthSession.Create( - sessionId: sessionId, - tenant: context.Tenant, - userKey: context.UserKey, - chainId: SessionChainId.Unassigned, - now: now, - expiresAt: expiresAt, - claims: context.Claims, - device: context.Device, - metadata: context.Metadata - ); - - var issued = new IssuedSession - { - Session = session, - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; - var kernel = _kernelFactory.Create(context.Tenant); + IssuedSession? issued = null; + await kernel.ExecuteAsync(async _ => { - var root = await kernel.GetSessionRootByUserAsync(context.UserKey) - ?? UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + var root = await kernel.GetRootByUserAsync(context.UserKey); + + bool isNewRoot = root is null; + long rootExpectedVersion = 0; + + if (isNewRoot) + { + root = UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + await kernel.CreateRootAsync(root); + } + else + { + if (root!.IsRevoked) + throw new SecurityException("Session root is revoked."); + + rootExpectedVersion = root.Version; + } UAuthSessionChain chain; + bool isNewChain = false; if (context.ChainId is not null) { - chain = await kernel.GetChainAsync(context.ChainId.Value) - ?? throw new SecurityException("Chain not found."); + if (isNewRoot) + throw new SecurityException("ChainId provided but session root does not exist."); + + chain = await kernel.GetChainAsync(context.ChainId.Value) ?? throw new SecurityException("Chain not found."); + + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked."); + + if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) + throw new SecurityException("Chain does not belong to the current user/tenant."); } else { + isNewChain = true; + chain = UAuthSessionChain.Create( SessionChainId.New(), root.RootId, @@ -87,17 +96,51 @@ await kernel.ExecuteAsync(async _ => root.SecurityVersion, ClaimsSnapshot.Empty); - await kernel.SaveChainAsync(chain); - root = root.AttachChain(chain, now); + await kernel.CreateChainAsync(chain); + + var updatedRoot = root.AttachChain(chain, now); + + if (isNewRoot) + { + await kernel.SaveRootAsync(updatedRoot, 0); + } + else + { + await kernel.SaveRootAsync(updatedRoot, rootExpectedVersion); + } + + root = updatedRoot; } - var boundSession = session.WithChain(chain.ChainId); + var session = UAuthSession.Create( + sessionId: sessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ); + + await kernel.CreateSessionAsync(session); + var bound = session.WithChain(chain.ChainId); + await kernel.SaveSessionAsync(bound, 0); + var updatedChain = chain.AttachSession(bound.SessionId); + await kernel.SaveChainAsync(updatedChain, chain.Version); - await kernel.SaveSessionAsync(boundSession); - await kernel.SetActiveSessionIdAsync(chain.ChainId, boundSession.SessionId); - await kernel.SaveSessionRootAsync(root); + issued = new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; }, ct); + if (issued == null) + throw new InvalidCastException("Can't issued session."); return issued; } @@ -118,41 +161,71 @@ public async Task RotateSessionAsync(SessionRotationContext conte expiresAt = absoluteExpiry; } - var issued = new IssuedSession + IssuedSession? issued = null; + + await kernel.ExecuteAsync(async _ => { - Session = UAuthSession.Create( + var root = await kernel.GetRootByUserAsync(context.UserKey); + if (root == null) + throw new SecurityException("Session root not found"); + + if (root.IsRevoked) + throw new SecurityException("Session root is revoked"); + + var oldSession = await kernel.GetSessionAsync(context.CurrentSessionId) + ?? throw new SecurityException("Session not found"); + + if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) + throw new SecurityException("Session is not valid"); + + if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new SecurityException("Security version mismatch"); + + var chain = await kernel.GetChainAsync(oldSession.ChainId) + ?? throw new SecurityException("Chain not found"); + + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked"); + + if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) + throw new SecurityException("Chain does not belong to the current user/tenant."); + + var newSessionUnbound = UAuthSession.Create( sessionId: newSessionId, tenant: context.Tenant, userKey: context.UserKey, chainId: SessionChainId.Unassigned, now: now, expiresAt: expiresAt, + securityVersion: root.SecurityVersion, device: context.Device, claims: context.Claims, metadata: context.Metadata - ), - OpaqueSessionId = opaqueSessionId, - IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid - }; + ); - await kernel.ExecuteAsync(async _ => - { - var oldSession = await kernel.GetSessionAsync(context.CurrentSessionId) - ?? throw new SecurityException("Session not found"); + issued = new IssuedSession + { + Session = newSessionUnbound, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; - if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) - throw new SecurityException("Session is not valid"); + var newSession = issued.Session.WithChain(chain.ChainId); - var chain = await kernel.GetChainAsync(oldSession.ChainId) - ?? throw new SecurityException("Chain not found"); + await kernel.CreateSessionAsync(newSession); + var chainExpected = chain.Version; + var updatedChain = chain.RotateSession(newSession.SessionId); + await kernel.SaveChainAsync(updatedChain, chainExpected); - var bound = issued.Session.WithChain(chain.ChainId); + //await kernel.SetActiveSessionIdAsync(chain.ChainId, newSession.SessionId); - await kernel.SaveSessionAsync(bound); - await kernel.SetActiveSessionIdAsync(chain.ChainId, bound.SessionId); - await kernel.RevokeSessionAsync(oldSession.SessionId, now); + var expected = oldSession.Version; + var revokedOld = oldSession.Revoke(now); + await kernel.SaveSessionAsync(revokedOld, expected); }, ct); + if (issued == null) + throw new InvalidCastException("Can't issued session."); return issued; } @@ -181,11 +254,22 @@ await kernel.ExecuteAsync(async _ => continue; if (!chain.IsRevoked) - await kernel.RevokeChainAsync(chain.ChainId, at); - - var activeSessionId = await kernel.GetActiveSessionIdAsync(chain.ChainId); - if (activeSessionId is not null) - await kernel.RevokeSessionAsync(activeSessionId.Value, at); + { + var expectedChainVersion = chain.Version; + var revokedChain = chain.Revoke(at); + await kernel.SaveChainAsync(revokedChain, expectedChainVersion); + } + + if (chain.ActiveSessionId is not null) + { + var session = await kernel.GetSessionAsync(chain.ActiveSessionId.Value); + if (session is not null && !session.IsRevoked) + { + var expectedSessionVersion = session.Version; + var revokedSession = session.Revoke(at); + await kernel.SaveSessionAsync(revokedSession, expectedSessionVersion); + } + } } }, ct); } @@ -194,7 +278,6 @@ await kernel.ExecuteAsync(async _ => public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeSessionRootAsync(userKey, at), ct); + await kernel.ExecuteAsync(_ => kernel.RevokeRootAsync(userKey, at), ct); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs index 795307ce..8252f34c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -7,6 +7,6 @@ internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext Log { public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) { - return issuer.IssueLoginSessionAsync(LoginContext, ct); + return issuer.IssueSessionAsync(LoginContext, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index 191aadf7..52d649b4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -10,10 +10,10 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthUserAccessor : IUserAccessor { - private readonly ISessionStoreKernelFactory _kernelFactory; + private readonly ISessionStoreFactory _kernelFactory; private readonly IUserIdConverter _userIdConverter; - public UAuthUserAccessor(ISessionStoreKernelFactory kernelFactory, IUserIdConverterResolver converterResolver) + public UAuthUserAccessor(ISessionStoreFactory kernelFactory, IUserIdConverterResolver converterResolver) { _kernelFactory = kernelFactory; _userIdConverter = converterResolver.GetConverter(); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 04d34fe4..2e35a6cb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -6,11 +6,11 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class UAuthSessionQueryService : ISessionQueryService { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IAuthFlowContextAccessor _authFlow; public UAuthSessionQueryService( - ISessionStoreKernelFactory storeFactory, + ISessionStoreFactory storeFactory, IAuthFlowContextAccessor authFlow) { _storeFactory = storeFactory; @@ -37,7 +37,7 @@ public Task> GetChainsByUserAsync(UserKey userK return CreateKernel().GetChainIdBySessionAsync(sessionId); } - private ISessionStoreKernel CreateKernel() + private ISessionStore CreateKernel() { var tenantId = _authFlow.Current.Tenant; return _storeFactory.Create(tenantId); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index 74abecdc..afe311bf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -9,12 +9,12 @@ namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthSessionValidator : ISessionValidator { - private readonly ISessionStoreKernelFactory _storeFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; public UAuthSessionValidator( - ISessionStoreKernelFactory storeFactory, + ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { @@ -41,7 +41,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); - var root = await kernel.GetSessionRootByUserAsync(session.UserKey); + var root = await kernel.GetRootByUserAsync(session.UserKey); if (root is null || root.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 6f0f3e80..478a44e6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull { services.AddDbContext(configureDb); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs similarity index 70% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index bf34a06f..1a0b8e2d 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -1,17 +1,18 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.EntityFrameworkCore; using System.Data; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -internal sealed class EfCoreSessionStoreKernel : ISessionStoreKernel +internal sealed class EfCoreSessionStore : ISessionStore { private readonly UltimateAuthSessionDbContext _db; private readonly TenantContext _tenant; - public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db, TenantContext tenant) + public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) { _db = db; _tenant = tenant; @@ -36,6 +37,11 @@ await strategy.ExecuteAsync(async () => await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } + catch (DbUpdateConcurrencyException) + { + await tx.RollbackAsync(ct); + throw new UAuthConcurrencyException("concurrency_conflict"); + } catch { await tx.RollbackAsync(ct); @@ -89,17 +95,32 @@ public async Task ExecuteAsync(Func x.Version).OriginalValue = expectedVersion; + + try + { + await Task.CompletedTask; + } + catch (DbUpdateConcurrencyException) + { + throw new UAuthConcurrencyException("session_concurrency_conflict"); + } + } + + public async Task CreateSessionAsync(UAuthSession session) { var projection = session.ToProjection(); - var exists = await _db.Sessions - .AnyAsync(x => x.SessionId == session.SessionId); + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); - if (exists) - _db.Sessions.Update(projection); - else - _db.Sessions.Add(projection); + _db.Sessions.Add(projection); } public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) @@ -128,17 +149,33 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs return projection?.ToDomain(); } - public async Task SaveChainAsync(UAuthSessionChain chain) + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) { var projection = chain.ToProjection(); - var exists = await _db.Chains - .AnyAsync(x => x.ChainId == chain.ChainId); + if (chain.Version != expectedVersion + 1) + throw new InvalidOperationException("Chain version must be incremented by domain."); + + // Concurrency için EF’e expectedVersion’ı original value olarak bildiriyoruz + _db.Entry(projection).State = EntityState.Modified; - if (exists) - _db.Chains.Update(projection); - else - _db.Chains.Add(projection); + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + public Task CreateChainAsync(UAuthSessionChain chain) + { + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + var projection = chain.ToProjection(); + + _db.Chains.Add(projection); + + return Task.CompletedTask; } public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) @@ -177,7 +214,7 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId _db.Chains.Update(projection); } - public async Task GetSessionRootByUserAsync(UserKey userKey) + public async Task GetRootByUserAsync(UserKey userKey) { var rootProjection = await _db.Roots .AsNoTracking() @@ -194,20 +231,35 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); } - public async Task SaveSessionRootAsync(UAuthSessionRoot root) + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) { var projection = root.ToProjection(); - var exists = await _db.Roots - .AnyAsync(x => x.RootId == root.RootId); + if (root.Version != expectedVersion + 1) + throw new InvalidOperationException("Root version must be incremented by domain."); + + _db.Entry(projection).State = EntityState.Modified; + + _db.Entry(projection) + .Property(x => x.Version) + .OriginalValue = expectedVersion; + + return Task.CompletedTask; + } + + public Task CreateRootAsync(UAuthSessionRoot root) + { + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + var projection = root.ToProjection(); + + _db.Roots.Add(projection); - if (exists) - _db.Roots.Update(projection); - else - _db.Roots.Add(projection); + return Task.CompletedTask; } - public async Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) { var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); @@ -247,7 +299,7 @@ public async Task> GetSessionsByChainAsync(SessionCh return projections.Select(x => x.ToDomain()).ToList(); } - public async Task GetSessionRootByIdAsync(SessionRootId rootId) + public async Task GetRootByIdAsync(SessionRootId rootId) { var rootProjection = await _db.Roots .AsNoTracking() diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs similarity index 70% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs index 240b9b9d..9200b8ef 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -4,18 +4,18 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; -public sealed class EfCoreSessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class EfCoreSessionStoreFactory : ISessionStoreFactory { private readonly IServiceProvider _sp; - public EfCoreSessionStoreKernelFactory(IServiceProvider sp) + public EfCoreSessionStoreFactory(IServiceProvider sp) { _sp = sp; } - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); + return ActivatorUtilities.CreateInstance(_sp, new TenantContext(tenant)); } // TODO: Implement global here diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs similarity index 52% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 893a73b0..267711c1 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernel.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -1,17 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Sessions.InMemory; -internal sealed class InMemorySessionStoreKernel : ISessionStoreKernel +internal sealed class InMemorySessionStore : ISessionStore { private readonly SemaphoreSlim _tx = new(1, 1); + private readonly object _lock = new(); private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _chains = new(); private readonly ConcurrentDictionary _roots = new(); - private readonly ConcurrentDictionary _activeSessions = new(); + //private readonly ConcurrentDictionary _activeSessions = new(); public async Task ExecuteAsync(Func action, CancellationToken ct = default) { @@ -39,14 +41,37 @@ public async Task ExecuteAsync(Func GetSessionAsync(AuthSessionId sessionId) => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + public Task GetSessionAsync(AuthSessionId sessionId) + => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); - public Task SaveSessionAsync(UAuthSession session) + public Task SaveSessionAsync(UAuthSession session, long expectedVersion) { + if (!_sessions.TryGetValue(session.SessionId, out var current)) + throw new UAuthNotFoundException("session_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("session_concurrency_conflict"); + _sessions[session.SessionId] = session; return Task.CompletedTask; } + public Task CreateSessionAsync(UAuthSession session) + { + lock (_lock) + { + if (_sessions.ContainsKey(session.SessionId)) + throw new UAuthConcurrencyException("session_already_exists"); + + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); + + _sessions[session.SessionId] = session; + } + + return Task.CompletedTask; + } + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) { if (!_sessions.TryGetValue(sessionId, out var session)) @@ -62,12 +87,34 @@ public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) public Task GetChainAsync(SessionChainId chainId) => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); - public Task SaveChainAsync(UAuthSessionChain chain) + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) { + if (!_chains.TryGetValue(chain.ChainId, out var current)) + throw new UAuthNotFoundException("chain_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("chain_concurrency_conflict"); + _chains[chain.ChainId] = chain; return Task.CompletedTask; } + public Task CreateChainAsync(UAuthSessionChain chain) + { + lock (_lock) + { + if (_chains.ContainsKey(chain.ChainId)) + throw new UAuthConcurrencyException("chain_already_exists"); + + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + _chains[chain.ChainId] = chain; + } + + return Task.CompletedTask; + } + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) { if (_chains.TryGetValue(chainId, out var chain)) @@ -77,28 +124,50 @@ public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) return Task.CompletedTask; } - public Task GetActiveSessionIdAsync(SessionChainId chainId) - => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); + //public Task GetActiveSessionIdAsync(SessionChainId chainId) + // => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); - public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - { - _activeSessions[chainId] = sessionId; - return Task.CompletedTask; - } + //public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + //{ + // _activeSessions[chainId] = sessionId; + // return Task.CompletedTask; + //} - public Task GetSessionRootByUserAsync(UserKey userKey) + public Task GetRootByUserAsync(UserKey userKey) => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); - public Task GetSessionRootByIdAsync(SessionRootId rootId) + public Task GetRootByIdAsync(SessionRootId rootId) => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); - public Task SaveSessionRootAsync(UAuthSessionRoot root) + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) { + if (!_roots.TryGetValue(root.UserKey, out var current)) + throw new UAuthNotFoundException("root_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); + _roots[root.UserKey] = root; return Task.CompletedTask; } - public Task RevokeSessionRootAsync(UserKey userKey, DateTimeOffset at) + public Task CreateRootAsync(UAuthSessionRoot root) + { + lock (_lock) + { + if (_roots.ContainsKey(root.UserKey)) + throw new UAuthConcurrencyException("root_already_exists"); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + _roots[root.UserKey] = root; + } + + return Task.CompletedTask; + } + + public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) { if (_roots.TryGetValue(userKey, out var root)) { @@ -143,10 +212,10 @@ public Task DeleteExpiredSessionsAsync(DateTimeOffset at) var revoked = session.Revoke(at); _sessions[kvp.Key] = revoked; - if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) - { - _activeSessions.TryRemove(revoked.ChainId, out _); - } + //if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) + //{ + // _activeSessions.TryRemove(revoked.ChainId, out _); + //} } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs similarity index 63% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs index 6bd845eb..af6b5e99 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreKernelFactory.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -4,12 +4,12 @@ namespace CodeBeam.UltimateAuth.Sessions.InMemory; -public sealed class InMemorySessionStoreKernelFactory : ISessionStoreKernelFactory +public sealed class InMemorySessionStoreFactory : ISessionStoreFactory { - private readonly ConcurrentDictionary _kernels = new(); + private readonly ConcurrentDictionary _kernels = new(); - public ISessionStoreKernel Create(TenantKey tenant) + public ISessionStore Create(TenantKey tenant) { - return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStoreKernel()); + return _kernels.GetOrAdd(tenant, _ => new InMemorySessionStore()); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index aebffb25..054fcffc 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemorySessions(this IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 6a858508..4b4f45d8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -21,6 +21,7 @@ public void Revoke_marks_session_as_revoked() chainId: SessionChainId.New(), now, now.AddMinutes(10), + 0, DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -45,6 +46,7 @@ public void Revoking_twice_returns_same_instance() SessionChainId.New(), now, now.AddMinutes(10), + 0, DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); From 554cb9b3f4bf908bdc24574d768547f4ee75ac21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 1 Mar 2026 01:28:24 +0300 Subject: [PATCH 08/29] Improved Session Domains & Basic Device Id Binding --- .../Components/Dialogs/IdentifierDialog.razor | 2 +- .../Components/Dialogs/SessionDialog.razor | 168 +++++++++++++++ .../Components/Pages/Home.razor | 4 + .../Components/Pages/Home.razor.cs | 6 +- .../wwwroot/app.css | 4 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Services/ISessionClient.cs | 21 ++ .../Services/IUAuthClient.cs | 1 + .../Services/UAuthClient.cs | 4 +- .../Services/UAuthSessionClient.cs | 91 ++++++++ .../Abstractions/Stores/ISessionStore.cs | 4 +- .../Contracts/Authority/AccessContext.cs | 3 + .../Session/Dtos/SessionChainDetailDto.cs | 15 ++ .../Session/Dtos/SessionChainSummaryDto.cs | 16 ++ .../Contracts/Session/Dtos/SessionInfoDto.cs | 10 + .../Domain/Session/AuthSessionId.cs | 41 +++- .../Domain/Session/SessionChainId.cs | 24 ++- .../Domain/Session/SessionRootId.cs | 24 ++- .../Domain/Session/UAuthSession.cs | 43 +--- .../Domain/Session/UAuthSessionChain.cs | 180 ++++++++++++---- .../Auth/Context/AccessContextFactory.cs | 1 + .../Defaults/UAuthActions.cs | 15 ++ .../Abstractions/ISessionEndpointHandler.cs | 20 ++ .../Abstractions/ISessionManagementHandler.cs | 11 - .../Endpoints/SessionEndpointHandler.cs | 204 ++++++++++++++++++ .../Endpoints/UAuthEndpointRegistrar.cs | 46 ++-- .../Extensions/ServiceCollectionExtensions.cs | 11 +- .../Flows/Refresh/SessionTouchService.cs | 18 +- .../Issuers/UAuthSessionIssuer.cs | 55 +++-- .../Orchestrator/AccessCommand.cs | 25 +++ .../Orchestrator/RevokeAllSessionsCommand.cs | 23 -- .../Services/ISessionApplicationService.cs | 21 ++ .../Services/SessionApplicationService.cs | 168 +++++++++++++++ .../Services/UAuthSessionManager.cs | 1 - .../Services/UAuthSessionValidator.cs | 44 +++- .../SessionChainProjection.cs | 15 +- .../Mappers/SessionChainProjectionMapper.cs | 22 +- .../Mappers/SessionProjectionMapper.cs | 4 - .../Stores/EfCoreSessionStore.cs | 68 +++++- .../InMemorySessionStore.cs | 74 ++++++- .../Core/UAuthSessionChainTests.cs | 77 ++++--- .../Core/UAuthSessionTests.cs | 2 - .../Helpers/TestAccessContext.cs | 2 + 43 files changed, 1339 insertions(+), 250 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index 7b95b01f..228afa24 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -4,7 +4,7 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService - + Identifier Management User: @AuthState?.Identity?.DisplayName diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor new file mode 100644 index 00000000..9a9c22de --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,168 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + + Identifiers + + + + + + + + + + + + + + + + + @* *@ + + + + + + + + + + + + + Cancel + OK + + + +@code { + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Sessions.GetMyChainsAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Sessions.GetMyChainsAsync(); + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + // private async Task CommittedItemChanges(SessionChainSummaryDto item) + // { + // UpdateUserIdentifierRequest updateRequest = new() + // { + // Id = item.Id, + // NewValue = item.Value + // }; + // var result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); + // if (result.IsSuccess) + // { + // Snackbar.Add("Identifier updated successfully", Severity.Success); + // } + // else + // { + // Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); + // } + + // await ReloadAsync(); + // return DataGridEditFormAction.Close; + // } + + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index f1008c9d..fab90a2c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -218,6 +218,10 @@ Account + + Manage Sessions + + Manage Identifiers diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 38a6f1cc..de4b2066 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -153,10 +153,14 @@ private string GetHealthText() private async Task OpenIdentifierDialog() { - await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), GetDialogOptions()); } + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), GetDialogOptions()); + } + private DialogOptions GetDialogOptions() { return new DialogOptions diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css index 202b506b..06a588be 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -91,3 +91,7 @@ h1:focus { .uauth-text-transform-none .mud-button { text-transform: none; } + +.uauth-dialog { + min-height: 62vh; +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 5c4e1525..31975e8a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -75,6 +75,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs new file mode 100644 index 00000000..29a9d870 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ISessionClient +{ + Task>> GetMyChainsAsync(PageRequest? request = null); + Task> GetMyChainAsync(SessionChainId chainId); + Task RevokeMyChainAsync(SessionChainId chainId); + Task RevokeOtherChainsAsync(); + Task RevokeAllMyChainsAsync(); + + + Task>> GetUserChainsAsync(UserKey userKey); + Task> GetUserChainAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId); + Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserRootAsync(UserKey userKey); + Task RevokeAllUserChainsAsync(UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index 272959ef..54dcf6bd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Client; public interface IUAuthClient { IFlowClient Flows { get; } + ISessionClient Sessions { get; } IUserClient Users { get; } IUserIdentifierClient Identifiers { get; } ICredentialClient Credentials { get; } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 40d44f2a..6df7e74d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -5,14 +5,16 @@ namespace CodeBeam.UltimateAuth.Client; internal sealed class UAuthClient : IUAuthClient { public IFlowClient Flows { get; } + public ISessionClient Sessions { get; } public IUserClient Users { get; } public IUserIdentifierClient Identifiers { get; } public ICredentialClient Credentials { get; } public IAuthorizationClient Authorization { get; } - public UAuthClient(IFlowClient flows, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) + public UAuthClient(IFlowClient flows, ISessionClient session, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) { Flows = flows; + Sessions = session; Users = users; Identifiers = identifiers; Credentials = credentials; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs new file mode 100644 index 00000000..cd7133d0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -0,0 +1,91 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthSessionClient : ISessionClient +{ + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public UAuthSessionClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + private string Url(string path) + => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + + public async Task>> GetMyChainsAsync(PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/session/me/chains"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetMyChainAsync(SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/session/me/chains/{chainId}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeMyChainAsync(SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/session/me/chains/{chainId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeOtherChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/session/me/revoke-others")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllMyChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/session/me/revoke-all")); + return UAuthResultMapper.From(raw); + } + + + public async Task>> GetUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains")); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetUserChainAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/{sessionId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeUserRootAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-root")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/revoke-all")); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 14775660..ccccd5f2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -16,14 +16,14 @@ public interface ISessionStore Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion); Task CreateChainAsync(UAuthSessionChain chain); Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - //Task GetActiveSessionIdAsync(SessionChainId chainId); - //Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId); + Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at); Task GetRootByUserAsync(UserKey userKey); Task GetRootByIdAsync(SessionRootId rootId); Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion); Task CreateRootAsync(UAuthSessionRoot root); Task RevokeRootAsync(UserKey userKey, DateTimeOffset at); + Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at); Task GetChainIdBySessionAsync(AuthSessionId sessionId); Task> GetChainsByUserAsync(UserKey userKey); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 320faef7..3335cf3c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -11,6 +11,7 @@ public sealed class AccessContext public TenantKey ActorTenant { get; init; } public bool IsAuthenticated { get; init; } public bool IsSystemActor { get; init; } + public SessionChainId? ActorChainId { get; } // Target public string? Resource { get; init; } @@ -38,6 +39,7 @@ internal AccessContext( TenantKey actorTenant, bool isAuthenticated, bool isSystemActor, + SessionChainId? actorChainId, string resource, UserKey? targetUserKey, TenantKey resourceTenant, @@ -48,6 +50,7 @@ internal AccessContext( ActorTenant = actorTenant; IsAuthenticated = isAuthenticated; IsSystemActor = isSystemActor; + ActorChainId = actorChainId; Resource = resource; TargetUserKey = targetUserKey; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs new file mode 100644 index 00000000..3843e4e7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionChainDetailDto( + SessionChainId ChainId, + string? DeviceName, + string? DeviceType, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt, + int RotationCount, + bool IsRevoked, + AuthSessionId? ActiveSessionId, + IReadOnlyList Sessions + ); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs new file mode 100644 index 00000000..aa9bc61f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionChainSummaryDto +{ + public required SessionChainId ChainId { get; init; } + public string? DeviceName { get; init; } + public string? DeviceType { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastSeenAt { get; init; } + public int RotationCount { get; init; } + public bool IsRevoked { get; init; } + public AuthSessionId? ActiveSessionId { get; init; } + public bool IsCurrentDevice { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs new file mode 100644 index 00000000..cbf966e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfoDto.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionInfoDto( + AuthSessionId SessionId, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + bool IsRevoked + ); diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index b978f3d4..4bc6988f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; // AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. [JsonConverter(typeof(AuthSessionIdJsonConverter))] -public readonly record struct AuthSessionId +public readonly record struct AuthSessionId : IParsable { public string Value { get; } @@ -16,19 +16,44 @@ private AuthSessionId(string value) public static bool TryCreate(string? raw, out AuthSessionId id) { - if (string.IsNullOrWhiteSpace(raw)) + if (IsValid(raw)) { - id = default; - return false; + id = new AuthSessionId(raw!); + return true; } - if (raw.Length < 32) + id = default; + return false; + } + + public static AuthSessionId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid AuthSessionId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result) + { + if (IsValid(s)) { - id = default; - return false; + result = new AuthSessionId(s!); + return true; } - id = new AuthSessionId(raw); + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length < 32) + return false; + return true; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs index c733e991..cd104b31 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionChainIdJsonConverter))] -public readonly record struct SessionChainId(Guid Value) +public readonly record struct SessionChainId(Guid Value) : IParsable { public static SessionChainId New() => new(Guid.NewGuid()); @@ -32,5 +32,27 @@ public static bool TryCreate(string raw, out SessionChainId id) return false; } + public static SessionChainId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionChainId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionChainId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionChainId(guid); + return true; + } + + result = default; + return false; + } + public override string ToString() => Value.ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs index 60a85cfb..20e0b713 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; [JsonConverter(typeof(SessionRootIdJsonConverter))] -public readonly record struct SessionRootId(Guid Value) +public readonly record struct SessionRootId(Guid Value) : IParsable { public static SessionRootId New() => new(Guid.NewGuid()); @@ -25,5 +25,27 @@ public static bool TryCreate(string raw, out SessionRootId id) return false; } + public static SessionRootId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionRootId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionRootId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionRootId(guid); + return true; + } + + result = default; + return false; + } + public override string ToString() => Value.ToString("N"); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index ddacbbc1..16d63263 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -11,11 +11,9 @@ public sealed class UAuthSession : IVersionedEntity public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public DateTimeOffset? LastSeenAt { get; } public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } - public DeviceContext Device { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } public long Version { get; } @@ -27,11 +25,9 @@ private UAuthSession( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, - DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -42,11 +38,9 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - LastSeenAt = lastSeenAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; - Device = device; Claims = claims; Metadata = metadata; Version = version; @@ -60,7 +54,6 @@ public static UAuthSession Create( DateTimeOffset now, DateTimeOffset expiresAt, long securityVersion, - DeviceContext device, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -71,37 +64,15 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - lastSeenAt: now, isRevoked: false, revokedAt: null, securityVersionAtCreation: securityVersion, - device: device, claims: claims ?? ClaimsSnapshot.Empty, metadata: metadata, version: 0 ); } - public UAuthSession Touch(DateTimeOffset at) - { - return new UAuthSession( - SessionId, - Tenant, - UserKey, - ChainId, - CreatedAt, - ExpiresAt, - at, - IsRevoked, - RevokedAt, - SecurityVersionAtCreation, - Device, - Claims, - Metadata, - Version + 1 - ); - } - public UAuthSession Revoke(DateTimeOffset at) { if (IsRevoked) return this; @@ -113,11 +84,9 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - LastSeenAt, true, at, SecurityVersionAtCreation, - Device, Claims, Metadata, Version + 1 @@ -131,11 +100,9 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - DateTimeOffset? lastSeenAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, - DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -147,18 +114,16 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - lastSeenAt, isRevoked, revokedAt, securityVersionAtCreation, - device, claims, metadata, version ); } - public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; @@ -166,9 +131,6 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) if (at >= ExpiresAt) return SessionState.Expired; - if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) - return SessionState.Expired; - return SessionState.Active; } @@ -184,15 +146,12 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - lastSeenAt: LastSeenAt, isRevoked: IsRevoked, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, - device: Device, claims: Claims, metadata: Metadata, version: Version + 1 ); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 5e16c30f..13f54b7a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -9,10 +9,17 @@ public sealed class UAuthSessionChain : IVersionedEntity public SessionRootId RootId { get; } public TenantKey Tenant { get; } public UserKey UserKey { get; } - public int RotationCount { get; } - public long SecurityVersionAtCreation { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset LastSeenAt { get; } + public DateTimeOffset? AbsoluteExpiresAt { get; } + public DeviceContext Device { get; } public ClaimsSnapshot ClaimsSnapshot { get; } public AuthSessionId? ActiveSessionId { get; } + public int RotationCount { get; } + public int TouchCount { get; } + public long SecurityVersionAtCreation { get; } + public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long Version { get; } @@ -22,10 +29,15 @@ private UAuthSessionChain( SessionRootId rootId, TenantKey tenant, UserKey userKey, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? absoluteExpiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, + int rotationCount, + int touchCount, + long securityVersionAtCreation, bool isRevoked, DateTimeOffset? revokedAt, long version) @@ -34,10 +46,15 @@ private UAuthSessionChain( RootId = rootId; Tenant = tenant; UserKey = userKey; - RotationCount = rotationCount; - SecurityVersionAtCreation = securityVersionAtCreation; + CreatedAt = createdAt; + LastSeenAt = lastSeenAt; + AbsoluteExpiresAt = absoluteExpiresAt; + Device = device; ClaimsSnapshot = claimsSnapshot; ActiveSessionId = activeSessionId; + RotationCount = rotationCount; + TouchCount = touchCount; + SecurityVersionAtCreation = securityVersionAtCreation; IsRevoked = isRevoked; RevokedAt = revokedAt; Version = version; @@ -48,27 +65,38 @@ public static UAuthSessionChain Create( SessionRootId rootId, TenantKey tenant, UserKey userKey, - long securityVersion, - ClaimsSnapshot claimsSnapshot) + DateTimeOffset createdAt, + DateTimeOffset? expiresAt, + DeviceContext device, + ClaimsSnapshot claimsSnapshot, + long securityVersion) { return new UAuthSessionChain( chainId, rootId, tenant, userKey, + createdAt, + createdAt, + expiresAt, + device, + claimsSnapshot, + null, rotationCount: 0, + touchCount: 0, securityVersionAtCreation: securityVersion, - claimsSnapshot: claimsSnapshot, - activeSessionId: null, isRevoked: false, revokedAt: null, version: 0 ); } - public UAuthSessionChain AttachSession(AuthSessionId sessionId) + public UAuthSessionChain AttachSession(AuthSessionId sessionId, DateTimeOffset now) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -76,19 +104,27 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId) RootId, Tenant, UserKey, - RotationCount, // Unchanged on first attach - SecurityVersionAtCreation, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, ClaimsSnapshot, activeSessionId: sessionId, - isRevoked: IsRevoked, - revokedAt: RevokedAt, - version: Version + 1 + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, + IsRevoked, + RevokedAt, + Version + 1 ); } - public UAuthSessionChain RotateSession(AuthSessionId sessionId) + public UAuthSessionChain RotateSession(AuthSessionId sessionId, DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) return this; return new UAuthSessionChain( @@ -96,19 +132,24 @@ public UAuthSessionChain RotateSession(AuthSessionId sessionId) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + activeSessionId: sessionId, RotationCount + 1, + TouchCount, SecurityVersionAtCreation, - ClaimsSnapshot, - activeSessionId: sessionId, - isRevoked: false, - revokedAt: null, - version: Version + 1 + IsRevoked, + RevokedAt, + Version + 1 ); } - public UAuthSessionChain Revoke(DateTimeOffset at) + public UAuthSessionChain Touch(DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) { - if (IsRevoked) + if (IsRevoked || IsExpired(now)) return this; return new UAuthSessionChain( @@ -116,13 +157,43 @@ public UAuthSessionChain Revoke(DateTimeOffset at) RootId, Tenant, UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + ActiveSessionId, RotationCount, + TouchCount + 1, SecurityVersionAtCreation, + IsRevoked, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain Revoke(DateTimeOffset now) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + now, + AbsoluteExpiresAt, + Device, ClaimsSnapshot, ActiveSessionId, + RotationCount, + TouchCount, + SecurityVersionAtCreation, isRevoked: true, - revokedAt: at, - version: Version + 1 + revokedAt: now, + Version + 1 ); } @@ -131,27 +202,52 @@ internal static UAuthSessionChain FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, - int rotationCount, - long securityVersionAtCreation, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? expiresAt, + DeviceContext device, ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, + int rotationCount, + int touchCount, + long securityVersionAtCreation, bool isRevoked, DateTimeOffset? revokedAt, long version) { return new UAuthSessionChain( - chainId, - rootId, - tenant, - userKey, - rotationCount, - securityVersionAtCreation, - claimsSnapshot, - activeSessionId, - isRevoked, - revokedAt, - version - ); + chainId, + rootId, + tenant, + userKey, + createdAt, + lastSeenAt, + expiresAt, + device, + claimsSnapshot, + activeSessionId, + rotationCount, + touchCount, + securityVersionAtCreation, + isRevoked, + revokedAt, + version + ); } + private bool IsExpired(DateTimeOffset at) => AbsoluteExpiresAt.HasValue && at >= AbsoluteExpiresAt.Value; + + public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + { + if (IsRevoked) + return SessionState.Revoked; + + if (IsExpired(at)) + return SessionState.Expired; + + if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + return SessionState.Expired; + + return SessionState.Active; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs index cd5c4e55..1ba6b9db 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -67,6 +67,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow, actorTenant: authFlow.Tenant, isAuthenticated: authFlow.IsAuthenticated, isSystemActor: authFlow.Tenant.IsSystem, + actorChainId: authFlow.Session?.ChainId, resource: resource, targetUserKey: targetUserKey, resourceTenant: resourceTenant, diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs index 8220ca7e..9195fe83 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -2,6 +2,21 @@ public static class UAuthActions { + public static class Sessions + { + public const string GetChainSelf = "sessions.getchain.self"; + public const string GetChainAdmin = "sessions.getchain.admin"; + public const string ListChainsSelf = "sessions.listchains.self"; + public const string ListChainsAdmin = "sessions.listchains.admin"; + public const string RevokeChainSelf = "sessions.revokechain.self"; + public const string RevokeChainAdmin = "sessions.revokechain.admin"; + public const string RevokeAllChainsSelf = "sessions.revokeallchains.self"; + public const string RevokeAllChainsAdmin = "sessions.revokeallchains.admin"; + public const string RevokeOtherChainsSelf = "sessions.revokeotherchains.self"; + public const string RevokeSessionAdmin = "sessions.revoke.admin"; + public const string RevokeRootAdmin = "sessions.revokeroot.admin"; + } + public static class Users { public const string Create = "users.create"; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs new file mode 100644 index 00000000..31681539 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ISessionEndpointHandler +{ + Task GetMyChainsAsync(HttpContext ctx); + Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeOtherChainsAsync(HttpContext ctx); + Task RevokeAllMyChainsAsync(HttpContext ctx); + + Task GetUserChainsAsync(UserKey userKey, HttpContext ctx); + Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx); + Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx); + Task RevokeRootAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs deleted file mode 100644 index a4bcd598..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionManagementHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints; - -public interface ISessionManagementHandler -{ - Task GetCurrentSessionAsync(HttpContext ctx); - Task GetAllSessionsAsync(HttpContext ctx); - Task RevokeSessionAsync(string sessionId, HttpContext ctx); - Task RevokeAllAsync(HttpContext ctx); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs new file mode 100644 index 00000000..c6cfb3fa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs @@ -0,0 +1,204 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Services; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class SessionEndpointHandler : ISessionEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessions; + + public SessionEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessions) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _sessions = sessions; + } + + public async Task GetMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainsAsync(access, flow.UserKey!.Value, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeUserChainAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeOtherChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated || flow.Session == null) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeOtherChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeOtherChainsAsync(access, flow.UserKey!.Value, flow.Session.ChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeAllMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeAllChainsAsync(access, flow.UserKey!.Value, null, ctx.RequestAborted); + return Results.Ok(); + } + + + public async Task GetUserChainsAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainsAsync(access, userKey, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeSessionAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeUserSessionAsync(access, userKey, sessionId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeUserChainAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeAllChainsAsync(access, userKey, exceptChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeRootAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeRootAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeRootAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 9396c317..e665fbeb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -74,26 +74,48 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); } + //var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); + if (options.Endpoints.Session != false) { var session = group.MapGroup("/session"); - session.MapPost("/current", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetCurrentSessionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + session.MapPost("/me/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/list", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.GetAllSessionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + session.MapPost("/me/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); - session.MapPost("/revoke/{sessionId}", async ([FromServices] ISessionManagementHandler h, string sessionId, HttpContext ctx) - => await h.RevokeSessionAsync(sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + session.MapPost("/me/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - session.MapPost("/revoke-all", async ([FromServices] ISessionManagementHandler h, HttpContext ctx) - => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - } + session.MapPost("/me/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); - //var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); + session.MapPost("/me/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + + adminUsers.MapPost("/{userKey}/sessions/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + adminUsers.MapPost("/{userKey}/sessions/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + adminUsers.MapPost("/{userKey}/sessions/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + adminUsers.MapPost("/{userKey}/sessions/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + } //if (options.EnableUserInfoEndpoints != false) //{ diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index f08a5c70..1fbf8a9d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -136,6 +136,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -218,19 +219,11 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddSingleton(); - //services.TryAddScoped>(); + services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); - - //services.TryAddScoped(); services.TryAddScoped(); - - //services.TryAddScoped>(); services.TryAddScoped(); // ------------------------------ diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index a2ea6223..2b29016e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -16,11 +16,11 @@ public SessionTouchService(ISessionStoreFactory kernelFactory) // That's why the service access store direcly: There is no security flow here, only validate and touch session. public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) { - if (!validation.IsValid || validation.SessionId is null) + if (!validation.IsValid || validation.ChainId is null) return SessionRefreshResult.ReauthRequired(); if (!policy.TouchInterval.HasValue) - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch: false); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); var kernel = _kernelFactory.Create(validation.Tenant); @@ -28,21 +28,21 @@ public async Task RefreshAsync(SessionValidationResult val await kernel.ExecuteAsync(async _ => { - var session = await kernel.GetSessionAsync(validation.SessionId.Value); + var chain = await kernel.GetChainAsync(validation.ChainId.Value); - if (session is null || session.IsRevoked) + if (chain is null || chain.IsRevoked) return; - if (sessionTouchMode == SessionTouchMode.IfNeeded && now - session.LastSeenAt < policy.TouchInterval.Value) + if (now - chain.LastSeenAt < policy.TouchInterval.Value) return; - var expectedVersion = session.Version; - var touched = session.Touch(now); + var expectedVersion = chain.Version; + var touched = chain.Touch(now); - await kernel.SaveSessionAsync(touched, expectedVersion); + await kernel.SaveChainAsync(touched, expectedVersion); didTouch = true; }, ct); - return SessionRefreshResult.Success(validation.SessionId.Value, didTouch); + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index cf4a5a55..ddcb53e4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -69,7 +69,6 @@ await kernel.ExecuteAsync(async _ => } UAuthSessionChain chain; - bool isNewChain = false; if (context.ChainId is not null) { @@ -86,15 +85,18 @@ await kernel.ExecuteAsync(async _ => } else { - isNewChain = true; chain = UAuthSessionChain.Create( SessionChainId.New(), root.RootId, context.Tenant, context.UserKey, - root.SecurityVersion, - ClaimsSnapshot.Empty); + now, + null, + context.Device, + ClaimsSnapshot.Empty, + root.SecurityVersion + ); await kernel.CreateChainAsync(chain); @@ -120,7 +122,6 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, - device: context.Device, claims: context.Claims, metadata: context.Metadata ); @@ -128,7 +129,7 @@ await kernel.ExecuteAsync(async _ => await kernel.CreateSessionAsync(session); var bound = session.WithChain(chain.ChainId); await kernel.SaveSessionAsync(bound, 0); - var updatedChain = chain.AttachSession(bound.SessionId); + var updatedChain = chain.AttachSession(bound.SessionId, now); await kernel.SaveChainAsync(updatedChain, chain.Version); issued = new IssuedSession @@ -198,7 +199,6 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, - device: context.Device, claims: context.Claims, metadata: context.Metadata ); @@ -214,11 +214,9 @@ await kernel.ExecuteAsync(async _ => await kernel.CreateSessionAsync(newSession); var chainExpected = chain.Version; - var updatedChain = chain.RotateSession(newSession.SessionId); + var updatedChain = chain.RotateSession(newSession.SessionId, now, context.Claims); await kernel.SaveChainAsync(updatedChain, chainExpected); - //await kernel.SetActiveSessionIdAsync(chain.ChainId, newSession.SessionId); - var expected = oldSession.Version; var revokedOld = oldSession.Revoke(now); await kernel.SaveSessionAsync(revokedOld, expected); @@ -238,7 +236,14 @@ public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessi public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeChainAsync(chainId, at), ct); + await kernel.ExecuteAsync(async _ => + { + var chain = await kernel.GetChainAsync(chainId); + if (chain is null) + return; + + await kernel.RevokeChainCascadeAsync(chainId, at); + }, ct); } public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) @@ -253,31 +258,21 @@ await kernel.ExecuteAsync(async _ => if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) continue; - if (!chain.IsRevoked) - { - var expectedChainVersion = chain.Version; - var revokedChain = chain.Revoke(at); - await kernel.SaveChainAsync(revokedChain, expectedChainVersion); - } - - if (chain.ActiveSessionId is not null) - { - var session = await kernel.GetSessionAsync(chain.ActiveSessionId.Value); - if (session is not null && !session.IsRevoked) - { - var expectedSessionVersion = session.Version; - var revokedSession = session.Revoke(at); - await kernel.SaveSessionAsync(revokedSession, expectedSessionVersion); - } - } + await kernel.RevokeChainCascadeAsync(chain.ChainId, at); } }, ct); } - // TODO: Discuss revoking chains/sessions when root is revoked public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { var kernel = _kernelFactory.Create(tenant); - await kernel.ExecuteAsync(_ => kernel.RevokeRootAsync(userKey, at), ct); + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetRootByUserAsync(userKey); + if (root is null) + return; + + await kernel.RevokeRootCascadeAsync(userKey, at); + }, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs new file mode 100644 index 00000000..07bdd410 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class AccessCommand : IAccessCommand +{ + private readonly Func _execute; + + public AccessCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} + +internal sealed class AccessCommand : IAccessCommand +{ + private readonly Func> _execute; + + public AccessCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs deleted file mode 100644 index 4fc9fcb5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public sealed class RevokeAllUserSessionsCommand : ISessionCommand -{ - public UserKey UserKey { get; } - - public RevokeAllUserSessionsCommand(UserKey userKey) - { - UserKey = userKey; - } - - // TODO: This method should call its own logic. Not revoke root. - public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) - { - await issuer.RevokeRootAsync(context.Tenant, UserKey, context.At, ct); - return Unit.Value; - } - -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs new file mode 100644 index 00000000..1df432b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface ISessionApplicationService +{ + Task> GetUserChainsAsync(AccessContext context,UserKey userKey, PageRequest request, CancellationToken ct = default); + + Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default); + + Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default); + + Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default); + + Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs new file mode 100644 index 00000000..da4ac522 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -0,0 +1,168 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class SessionApplicationService : ISessionApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ISessionStoreFactory _storeFactory; + private readonly IClock _clock; + + public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _storeFactory = storeFactory; + _clock = clock; + } + + public async Task> GetUserChainsAsync(AccessContext context, UserKey userKey, PageRequest request, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + request = request.Normalize(); + var chains = await store.GetChainsByUserAsync(userKey); + var actorChainId = context.ActorChainId; + + if (!string.IsNullOrWhiteSpace(request.SortBy)) + { + chains = request.SortBy switch + { + nameof(SessionChainSummaryDto.CreatedAt) => + request.Descending + ? chains.OrderByDescending(x => x.Version).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.RotationCount) => + request.Descending + ? chains.OrderByDescending(x => x.RotationCount).ToList() + : chains.OrderBy(x => x.RotationCount).ToList(), + + _ => chains + }; + } + + var total = chains.Count; + + var pageItems = chains + .Skip((request.PageNumber - 1) * request.PageSize) + .Take(request.PageSize) + .Select(c => new SessionChainSummaryDto + { + ChainId = c.ChainId, + DeviceName = null, + DeviceType = null, + CreatedAt = DateTimeOffset.MinValue, + LastSeenAt = null, + RotationCount = c.RotationCount, + IsRevoked = c.IsRevoked, + ActiveSessionId = c.ActiveSessionId, + IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value + }) + .ToList(); + + return new PagedResult(pageItems, total, request.PageNumber, request.PageSize, request.SortBy, request.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + + var chain = await store.GetChainAsync(chainId) ?? throw new InvalidOperationException("chain_not_found"); + + if (chain.UserKey != userKey) + throw new UnauthorizedAccessException(); + + var sessions = await store.GetSessionsByChainAsync(chainId); + + return new SessionChainDetailDto( + chain.ChainId, + null, + null, + DateTimeOffset.MinValue, + null, + chain.RotationCount, + chain.IsRevoked, + chain.ActiveSessionId, + sessions.Select(s => new SessionInfoDto(s.SessionId, s.CreatedAt, s.ExpiresAt, s.IsRevoked)).ToList()); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + var session = await store.GetSessionAsync(sessionId) + ?? throw new InvalidOperationException("session_not_found"); + + if (session.UserKey != userKey) + throw new UnauthorizedAccessException(); + + var expected = session.Version; + var revoked = session.Revoke(now); + + await store.SaveSessionAsync(revoked, expected); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default) + { + await RevokeAllChainsAsync(context, userKey, currentChainId, ct); + } + + public async Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var chains = await store.GetChainsByUserAsync(userKey); + + foreach (var chain in chains) + { + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await store.RevokeChainCascadeAsync(chain.ChainId, _clock.UtcNow); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeRootCascadeAsync(userKey, _clock.UtcNow); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs index 7cd1b428..bf83fcfc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs @@ -4,7 +4,6 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; -// TODO: Add wrapper service in client project. Validate method also may add. namespace CodeBeam.UltimateAuth.Server.Services; internal sealed class UAuthSessionManager : IUAuthSessionManager diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index afe311bf..33d4a48f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace CodeBeam.UltimateAuth.Server.Services; @@ -33,27 +34,54 @@ public async Task ValidateSessionAsync(SessionValidatio if (session is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); - var state = session.GetState(context.Now, _options.Session.IdleTimeout); + var state = session.GetState(context.Now); if (state != SessionState.Active) - return SessionValidationResult.Invalid(state, sessionId: session.SessionId, chainId: session.ChainId); + return SessionValidationResult.Invalid(state, session.UserKey, session.SessionId, session.ChainId); var chain = await kernel.GetChainAsync(session.ChainId); if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); + var chainState = chain.GetState(context.Now, _options.Session.IdleTimeout); + if (chainState != SessionState.Active) + return SessionValidationResult.Invalid(chainState, chain.UserKey, session.SessionId, chain.ChainId); + + //if (chain.ActiveSessionId != session.SessionId) + // return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.ChainId != session.ChainId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.Tenant != context.Tenant) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + var root = await kernel.GetRootByUserAsync(session.UserKey); if (root is null || root.IsRevoked) - return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId, root?.RootId); + return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId); + + if (chain.RootId != root.RootId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); if (session.SecurityVersionAtCreation != root.SecurityVersion) return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); - // TODO: Implement device id, AllowAndRebind behavior and check device mathing in blazor server circuit and external http calls. - // Currently this line has error on refresh flow. - //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) - // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); + if (chain.Device.HasDeviceId && context.Device.HasDeviceId) + { + if (!Equals(chain.Device.DeviceId, context.Device.DeviceId)) + { + if (_options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + + //if (_options.Session.DeviceMismatchBehavior == AllowAndRebind) + } + } + //else + //{ + // // Add SessionValidatorOrigin to seperate UserRequest or background task + // return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + //} var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); - return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, session.Device.DeviceId); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index ff2032a6..9619d3f6 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -8,20 +8,21 @@ internal sealed class SessionChainProjection public long Id { get; set; } public SessionChainId ChainId { get; set; } = default!; - public SessionRootId RootId { get; } - + public SessionRootId RootId { get; set; } public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } - public int RotationCount { get; set; } - public long SecurityVersionAtCreation { get; set; } - + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; set; } + public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public DeviceContext Device { get; set; } public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; - public AuthSessionId? ActiveSessionId { get; set; } + public int RotationCount { get; set; } + public int TouchCount { get; set; } + public long SecurityVersionAtCreation { get; set; } public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } - public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index cb1494ea..335e598e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -11,10 +11,15 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) p.RootId, p.Tenant, p.UserKey, - p.RotationCount, - p.SecurityVersionAtCreation, + p.CreatedAt, + p.LastSeenAt, + p.AbsoluteExpiresAt, + p.Device, p.ClaimsSnapshot, p.ActiveSessionId, + p.RotationCount, + p.TouchCount, + p.SecurityVersionAtCreation, p.IsRevoked, p.RevokedAt, p.Version @@ -26,15 +31,18 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) return new SessionChainProjection { ChainId = chain.ChainId, + RootId = chain.RootId, Tenant = chain.Tenant, UserKey = chain.UserKey, - - RotationCount = chain.RotationCount, - SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, - ActiveSessionId = chain.ActiveSessionId, - + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, IsRevoked = chain.IsRevoked, RevokedAt = chain.RevokedAt, Version = chain.Version diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 688bb9e0..9b591691 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,11 +13,9 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.LastSeenAt, p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, - p.Device, p.Claims, p.Metadata, p.Version @@ -35,13 +33,11 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - LastSeenAt = s.LastSeenAt, IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, - Device = s.Device, Claims = s.Claims, Metadata = s.Metadata, Version = s.Version diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 1a0b8e2d..74979da7 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -316,7 +316,6 @@ public async Task> GetSessionsByChainAsync(SessionCh return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); } - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) { var projections = await _db.Sessions @@ -330,4 +329,71 @@ public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) } } + public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) + { + var chainProjection = await _db.Chains + .SingleOrDefaultAsync(x => x.ChainId == chainId); + + if (chainProjection is null) + return; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revoked = session.Revoke(at); + + _db.Sessions.Update(revoked.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at) + { + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); + + if (rootProjection is null) + return; + + var chainProjections = await _db.Chains.Where(x => x.UserKey == userKey).ToListAsync(); + + foreach (var chainProjection in chainProjections) + { + var chainId = chainProjection.ChainId; + + var sessionProjections = await _db.Sessions.Where(x => x.ChainId == chainId && !x.IsRevoked).ToListAsync(); + + foreach (var sessionProjection in sessionProjections) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + + _db.Sessions.Update(revokedSession.ToProjection()); + } + + if (!chainProjection.IsRevoked) + { + var chain = chainProjection.ToDomain(); + var revokedChain = chain.Revoke(at); + + _db.Chains.Update(revokedChain.ToProjection()); + } + } + + if (!rootProjection.IsRevoked) + { + var root = rootProjection.ToDomain(); + var revokedRoot = root.Revoke(at); + + _db.Roots.Update(revokedRoot.ToProjection()); + } + } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 267711c1..273c755e 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -13,7 +13,6 @@ internal sealed class InMemorySessionStore : ISessionStore private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _chains = new(); private readonly ConcurrentDictionary _roots = new(); - //private readonly ConcurrentDictionary _activeSessions = new(); public async Task ExecuteAsync(Func action, CancellationToken ct = default) { @@ -221,4 +220,77 @@ public Task DeleteExpiredSessionsAsync(DateTimeOffset at) return Task.CompletedTask; } + + public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) + { + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chainId] = revokedChain; + } + + var sessions = _sessions.Values + .Where(s => s.ChainId == chainId) + .ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at) + { + lock (_lock) + { + if (!_roots.TryGetValue(userKey, out var root)) + return Task.CompletedTask; + + var chains = _chains.Values + .Where(c => c.UserKey == userKey && c.Tenant == root.Tenant) + .ToList(); + + foreach (var chain in chains) + { + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chain.ChainId] = revokedChain; + } + + var sessions = _sessions.Values + .Where(s => s.ChainId == chain.ChainId) + .ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + if (!root.IsRevoked) + { + var revokedRoot = root.Revoke(at); + _roots[userKey] = revokedRoot; + } + } + + return Task.CompletedTask; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 8c010928..2219f194 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -20,8 +21,12 @@ public void New_chain_has_expected_initial_state() SessionRootId.New(), tenant: TenantKey.Single, userKey: UserKey.FromString("user-1"), - securityVersion: 0, - ClaimsSnapshot.Empty); + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); Assert.Equal(0, chain.RotationCount); Assert.Null(chain.ActiveSessionId); @@ -34,13 +39,17 @@ public void Rotating_chain_sets_active_session_and_increments_rotation() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var sessionId = CreateSessionId("s1"); - var rotated = chain.RotateSession(sessionId); + var rotated = chain.RotateSession(sessionId, DateTimeOffset.UtcNow); Assert.Equal(1, rotated.RotationCount); Assert.Equal(sessionId, rotated.ActiveSessionId); @@ -53,13 +62,17 @@ public void Multiple_rotations_increment_rotation_count() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); - var first = chain.RotateSession(CreateSessionId("s1")); - var second = first.RotateSession(CreateSessionId("s2")); + var first = chain.RotateSession(CreateSessionId("s1"), DateTimeOffset.UtcNow); + var second = first.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); Assert.Equal(2, second.RotationCount); Assert.Equal(CreateSessionId("s2"), second.ActiveSessionId); @@ -73,13 +86,17 @@ public void Revoked_chain_does_not_rotate() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked = chain.Revoke(now); - var rotated = revoked.RotateSession(CreateSessionId("s2")); + var rotated = revoked.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); Assert.Same(revoked, rotated); Assert.True(rotated.IsRevoked); @@ -93,10 +110,14 @@ public void Revoking_chain_sets_revocation_fields() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked = chain.Revoke(now); @@ -112,10 +133,14 @@ public void Revoking_already_revoked_chain_is_idempotent() var chain = UAuthSessionChain.Create( SessionChainId.New(), SessionRootId.New(), - TenantKey.Single, - UserKey.FromString("user-1"), - 0, - ClaimsSnapshot.Empty); + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); var revoked1 = chain.Revoke(now); var revoked2 = revoked1.Revoke(now.AddMinutes(1)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index 4b4f45d8..e988dc1d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -22,7 +22,6 @@ public void Revoke_marks_session_as_revoked() now, now.AddMinutes(10), 0, - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -47,7 +46,6 @@ public void Revoking_twice_returns_same_instance() now, now.AddMinutes(10), 0, - DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index 6e1ac0ea..def622e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -13,6 +13,7 @@ public static AccessContext WithAction(string action) actorTenant: TenantKey.Single, isAuthenticated: false, isSystemActor: false, + actorChainId: null, resource: "test", targetUserKey: null, resourceTenant: TenantKey.Single, @@ -30,6 +31,7 @@ public static AccessContext ForUser(UserKey userKey, string action, TenantKey? t actorTenant: t, isAuthenticated: true, isSystemActor: false, + actorChainId: null, resource: "identifier", targetUserKey: userKey, resourceTenant: t, From ebfe0764e1780114254bcf51d79d7732918fd67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 1 Mar 2026 18:08:44 +0300 Subject: [PATCH 09/29] Improved Device Context Properties --- .../Components/Dialogs/SessionDialog.razor | 81 +++++++++++------ .../Components/Pages/Login.razor.cs | 2 +- .../Program.cs | 16 +++- .../Pages/Home.razor.cs | 3 +- .../Services/ISessionClient.cs | 4 +- .../Services/UAuthSessionClient.cs | 4 +- .../Abstractions/Stores/ISessionStore.cs | 3 +- .../Contracts/Authority/DeviceInfo.cs | 12 ++- .../Contracts/Login/LoginRequest.cs | 1 - .../Session/Dtos/SessionChainSummaryDto.cs | 6 +- .../Domain/Device/DeviceContext.cs | 51 ++++++++++- .../Domain/Session/UAuthSessionRoot.cs | 57 +++++------- .../Endpoints/LoginEndpointHandler.cs | 1 - .../Endpoints/PkceEndpointHandler.cs | 1 - .../Flows/Login/LoginOrchestrator.cs | 4 +- .../Device/DeviceContextFactory.cs | 11 ++- .../Infrastructure/Device/DeviceResolver.cs | 91 ++++++++++++++++--- .../Issuers/UAuthSessionIssuer.cs | 50 +++------- .../Services/SessionApplicationService.cs | 29 +++++- .../Services/UAuthSessionValidator.cs | 6 +- .../Data/UAuthSessionDbContext.cs | 10 +- .../SessionRootProjection.cs | 5 +- .../Mappers/SessionRootProjectionMapper.cs | 10 +- .../Stores/EfCoreSessionStore.cs | 26 +++++- .../InMemorySessionStore.cs | 34 +++---- .../Core/RefreshTokenValidatorTests.cs | 6 +- .../Helpers/TestDevice.cs | 2 +- .../Server/LoginOrchestratorTests.cs | 55 ++++------- .../UserIdentifierApplicationServiceTests.cs | 2 +- 29 files changed, 357 insertions(+), 226 deletions(-) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor index 9a9c22de..e712445a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -3,6 +3,7 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject NavigationManager Nav @@ -10,28 +11,32 @@ User: @AuthState?.Identity?.DisplayName + + Logout All Devices + Logout Other Devices + - - Identifiers - - - + Identifiers + + - + + + - - @* *@ + + @@ -140,27 +145,47 @@ } } - // private async Task CommittedItemChanges(SessionChainSummaryDto item) - // { - // UpdateUserIdentifierRequest updateRequest = new() - // { - // Id = item.Id, - // NewValue = item.Value - // }; - // var result = await UAuthClient.Identifiers.UpdateSelfAsync(updateRequest); - // if (result.IsSuccess) - // { - // Snackbar.Add("Identifier updated successfully", Severity.Success); - // } - // else - // { - // Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); - // } - - // await ReloadAsync(); - // return DataGridEditFormAction.Close; - // } + private async Task LogoutAllAsync() + { + var result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + private async Task LogoutAllExceptThisAsync() + { + var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all other devices", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutChainAsync(SessionChainId chainId) + { + var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } private void Submit() => MudDialog.Close(DialogResult.Ok(true)); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index 757ca466..b07fbb46 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -84,7 +84,7 @@ private async Task ProgrammaticLogin() { Identifier = "admin", Secret = "admin", - Device = DeviceContext.FromDeviceId(deviceId), + //Device = DeviceContext.Create(deviceId, null, null, null, null, null), }; await UAuthClient.Flows.LoginAsync(request, "/home"); } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 69d2cefe..e12f87f9 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,23 +1,24 @@ using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; +using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference.Extensions; -using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.HttpOverrides; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; -using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; var builder = WebApplication.CreateBuilder(args); @@ -66,6 +67,13 @@ builder.Services.AddScoped(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; +}); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -83,6 +91,8 @@ await seedRunner.RunAsync(null); } +app.UseForwardedHeaders(); + app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 55315ca7..6ecdf083 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs @@ -41,12 +41,11 @@ private void OnDiagnosticsChanged() private async Task ProgrammaticLogin() { - var device = await DeviceIdProvider.GetOrCreateAsync(); + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { Identifier = "admin", Secret = "admin", - Device = DeviceContext.FromDeviceId(device), }; await UAuthClient.Flows.LoginAsync(request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs index 29a9d870..d7f0c327 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs @@ -6,9 +6,9 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface ISessionClient { Task>> GetMyChainsAsync(PageRequest? request = null); - Task> GetMyChainAsync(SessionChainId chainId); + Task> GetMyChainDetailAsync(SessionChainId chainId); Task RevokeMyChainAsync(SessionChainId chainId); - Task RevokeOtherChainsAsync(); + Task RevokeMyOtherChainsAsync(); Task RevokeAllMyChainsAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index cd7133d0..bb66f333 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -28,7 +28,7 @@ public async Task>> GetMyChainsA return UAuthResultMapper.FromJson>(raw); } - public async Task> GetMyChainAsync(SessionChainId chainId) + public async Task> GetMyChainDetailAsync(SessionChainId chainId) { var raw = await _request.SendFormAsync(Url($"/session/me/chains/{chainId}")); return UAuthResultMapper.FromJson(raw); @@ -40,7 +40,7 @@ public async Task RevokeMyChainAsync(SessionChainId chainId) return UAuthResultMapper.From(raw); } - public async Task RevokeOtherChainsAsync() + public async Task RevokeMyOtherChainsAsync() { var raw = await _request.SendFormAsync(Url("/session/me/revoke-others")); return UAuthResultMapper.From(raw); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index ccccd5f2..308cc41d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -26,7 +26,8 @@ public interface ISessionStore Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at); Task GetChainIdBySessionAsync(AuthSessionId sessionId); - Task> GetChainsByUserAsync(UserKey userKey); + Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false); + Task> GetChainsByRootAsync(SessionRootId rootId); Task> GetSessionsByChainAsync(SessionChainId chainId); Task DeleteExpiredSessionsAsync(DateTimeOffset at); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs index 38760414..10c097d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs @@ -6,11 +6,11 @@ public sealed class DeviceInfo { public required DeviceId DeviceId { get; init; } + // TODO: Implement device type and device limits /// - /// High-level platform classification (web, mobile, desktop, iot). - /// Used for analytics and policy decisions. + /// Device type that can be used for device limits. Sends with header "X-Device-Type" or form field "device_type". Examples: "web", "mobile", "desktop", "tablet", "iot". /// - public string? Platform { get; init; } + public string? DeviceType { get; init; } /// /// Operating system information (e.g. iOS 17, Android 14, Windows 11). @@ -22,6 +22,12 @@ public sealed class DeviceInfo /// public string? Browser { get; init; } + /// + /// High-level platform classification (web, mobile, desktop, iot). + /// Used for analytics and policy decisions. + /// + public string? Platform { get; init; } + /// /// Raw user-agent string (optional). /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 23769bec..58aac3b8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -9,7 +9,6 @@ public sealed record LoginRequest public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; public DateTimeOffset? At { get; init; } - public required DeviceContext Device { get; init; } public IReadOnlyDictionary? Metadata { get; init; } /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs index aa9bc61f..0ad1c471 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs @@ -5,12 +5,16 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record SessionChainSummaryDto { public required SessionChainId ChainId { get; init; } - public string? DeviceName { get; init; } public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? LastSeenAt { get; init; } public int RotationCount { get; init; } + public int TouchCount { get; init; } public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } public AuthSessionId? ActiveSessionId { get; init; } public bool IsCurrentDevice { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index fcc44dde..23372748 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -3,21 +3,64 @@ public sealed class DeviceContext { public DeviceId? DeviceId { get; init; } + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + public string? IpAddress { get; init; } public bool HasDeviceId => DeviceId is not null; - private DeviceContext(DeviceId? deviceId) + private DeviceContext( + DeviceId? deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) { DeviceId = deviceId; + DeviceType = deviceType; + Platform = platform; + OperatingSystem = operatingSystem; + Browser = browser; + IpAddress = ipAddress; } - public static DeviceContext Anonymous() => new(null); + public static DeviceContext Anonymous() + => new( + deviceId: null, + deviceType: null, + platform: null, + operatingSystem: null, + browser: null, + ipAddress: null); - public static DeviceContext FromDeviceId(DeviceId deviceId) => new(deviceId); + public static DeviceContext Create( + DeviceId deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) + { + return new DeviceContext( + deviceId, + Normalize(deviceType), + Normalize(platform), + Normalize(operatingSystem), + Normalize(browser), + Normalize(ipAddress)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); // DeviceInfo is a transport object. // AuthFlowContextFactory changes it to a useable DeviceContext // DeviceContext doesn't have fields like IsTrusted etc. It's authority layer's responsibility. - // IP, Geo, Fingerprint, Platform, UA will be added here. + // Geo and Fingerprint will be added here. } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index e3279415..483d06a8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -6,51 +6,54 @@ namespace CodeBeam.UltimateAuth.Core.Domain; public sealed class UAuthSessionRoot : IVersionedEntity { public SessionRootId RootId { get; } - public UserKey UserKey { get; } public TenantKey Tenant { get; } + public UserKey UserKey { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset? UpdatedAt { get; } + public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } + public long SecurityVersion { get; } - public IReadOnlyList Chains { get; } - public DateTimeOffset LastUpdatedAt { get; } public long Version { get; } private UAuthSessionRoot( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt, long version) { RootId = rootId; Tenant = tenant; UserKey = userKey; + CreatedAt = createdAt; + UpdatedAt = updatedAt; IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; - Chains = chains; - LastUpdatedAt = lastUpdatedAt; Version = version; } public static UAuthSessionRoot Create( TenantKey tenant, UserKey userKey, - DateTimeOffset issuedAt) + DateTimeOffset at) { return new UAuthSessionRoot( SessionRootId.New(), tenant, userKey, + at, + null, false, null, 0, - chains: Array.Empty(), - issuedAt, 0 ); } @@ -61,11 +64,11 @@ public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) RootId, Tenant, UserKey, + CreatedAt, + at, IsRevoked, RevokedAt, SecurityVersion + 1, - Chains, - at, Version + 1 ); } @@ -79,29 +82,11 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) RootId, Tenant, UserKey, + CreatedAt, + at, true, at, SecurityVersion + 1, - Chains, - at, - Version + 1 - ); - } - - public UAuthSessionRoot AttachChain(UAuthSessionChain chain, DateTimeOffset at) - { - if (IsRevoked) - return this; - - return new UAuthSessionRoot( - RootId, - Tenant, - UserKey, - IsRevoked, - RevokedAt, - SecurityVersion, - Chains.Concat(new[] { chain }).ToArray(), - at, Version + 1 ); } @@ -110,22 +95,22 @@ internal static UAuthSessionRoot FromProjection( SessionRootId rootId, TenantKey tenant, UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, - IReadOnlyList chains, - DateTimeOffset lastUpdatedAt, long version) { return new UAuthSessionRoot( rootId, tenant, userKey, + createdAt, + updatedAt, isRevoked, revokedAt, securityVersion, - chains, - lastUpdatedAt, version ); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 4a499d2d..07c33dc3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -58,7 +58,6 @@ public async Task LoginAsync(HttpContext ctx) Secret = secret, Tenant = authFlow.Tenant, At = _clock.UtcNow, - Device = authFlow.Device, RequestTokens = authFlow.AllowsTokenIssuance }; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 195ae018..280806d8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -129,7 +129,6 @@ public async Task CompleteAsync(HttpContext ctx) Secret = request.Secret, Tenant = authContext.Tenant, At = _clock.UtcNow, - Device = authContext.Device, RequestTokens = authContext.AllowsTokenIssuance }; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 4486152b..a7c4ab45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -199,7 +199,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, Now = now, - Device = request.Device, + Device = flow.Device, Claims = claims, ChainId = request.ChainId, Metadata = SessionMetadata.Empty, @@ -230,7 +230,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } await _events.DispatchAsync( - new UserLoggedInContext(request.Tenant, userKey.Value, now, request.Device, issuedSession.Session.SessionId)); + new UserLoggedInContext(request.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs index 0a3a41b6..e1e7dccd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs @@ -7,9 +7,16 @@ internal sealed class DeviceContextFactory : IDeviceContextFactory { public DeviceContext Create(DeviceInfo device) { - if (string.IsNullOrWhiteSpace(device.DeviceId.Value)) + if (device is null || string.IsNullOrWhiteSpace(device.DeviceId.Value)) return DeviceContext.Anonymous(); - return DeviceContext.FromDeviceId(device.DeviceId); + return DeviceContext.Create( + deviceId: device.DeviceId, + deviceType: device.DeviceType, + platform: device.Platform, + operatingSystem: device.OperatingSystem, + browser: device.Browser, + ipAddress: device.IpAddress + ); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 3ac067a1..20022912 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; +// TODO: This is a very basic implementation. +// Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. (Add IDeviceInfoParser) public sealed class DeviceResolver : IDeviceResolver { public DeviceInfo Resolve(HttpContext context) @@ -14,17 +16,24 @@ public DeviceInfo Resolve(HttpContext context) var request = context.Request; var rawDeviceId = ResolveRawDeviceId(context); - DeviceId.TryCreate(rawDeviceId, out var deviceId); + if (!DeviceId.TryCreate(rawDeviceId, out var deviceId)) + { + //throw new InvalidOperationException("device_id_required"); + } - return new DeviceInfo + var ua = request.Headers.UserAgent.ToString(); + var deviceInfo = new DeviceInfo { DeviceId = deviceId, - Platform = ResolvePlatform(request), - UserAgent = request.Headers.UserAgent.ToString(), - IpAddress = context.Connection.RemoteIpAddress?.ToString() + Platform = ResolvePlatform(ua), + OperatingSystem = ResolveOperatingSystem(ua), + Browser = ResolveBrowser(ua), + UserAgent = ua, + IpAddress = ResolveIp(context) }; - } + return deviceInfo; + } private static string? ResolveRawDeviceId(HttpContext context) { @@ -42,16 +51,70 @@ public DeviceInfo Resolve(HttpContext context) return null; } - private static string? ResolvePlatform(HttpRequest request) + private static string? ResolvePlatform(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("ipad") || s.Contains("tablet") || s.Contains("sm-t") /* bazı samsung tabletler */) + return "tablet"; + + if (s.Contains("mobi") || s.Contains("iphone") || s.Contains("android")) + return "mobile"; + + return "desktop"; + } + + private static string? ResolveOperatingSystem(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("iphone") || s.Contains("ipad") || s.Contains("cpu os") || s.Contains("ios")) + return "ios"; + + if (s.Contains("android")) + return "android"; + + if (s.Contains("windows nt")) + return "windows"; + + if (s.Contains("mac os x") || s.Contains("macintosh")) + return "macos"; + + if (s.Contains("linux")) + return "linux"; + + return "unknown"; + } + + private static string? ResolveBrowser(string ua) + { + var s = ua.ToLowerInvariant(); + + if (s.Contains("edg/")) + return "edge"; + + if (s.Contains("opr/") || s.Contains("opera")) + return "opera"; + + if (s.Contains("chrome/") && !s.Contains("chromium/")) + return "chrome"; + + if (s.Contains("safari/") && !s.Contains("chrome/") && !s.Contains("crios/")) + return "safari"; + + if (s.Contains("firefox/")) + return "firefox"; + + return "unknown"; + } + + private static string? ResolveIp(HttpContext context) { - var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (ua.Contains("android")) return "android"; - if (ua.Contains("iphone") || ua.Contains("ipad")) return "ios"; - if (ua.Contains("windows")) return "windows"; - if (ua.Contains("mac os")) return "macos"; - if (ua.Contains("linux")) return "linux"; + if (!string.IsNullOrWhiteSpace(forwarded)) + return forwarded.Split(',')[0].Trim(); - return "web"; + return context.Connection.RemoteIpAddress?.ToString(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index ddcb53e4..1dc924f2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; @@ -52,73 +53,51 @@ await kernel.ExecuteAsync(async _ => { var root = await kernel.GetRootByUserAsync(context.UserKey); - bool isNewRoot = root is null; - long rootExpectedVersion = 0; - - if (isNewRoot) + if (root is null) { root = UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); await kernel.CreateRootAsync(root); } - else + else if (root.IsRevoked) { - if (root!.IsRevoked) - throw new SecurityException("Session root is revoked."); - - rootExpectedVersion = root.Version; + throw new UAuthValidationException("Session root revoked."); } UAuthSessionChain chain; if (context.ChainId is not null) { - if (isNewRoot) - throw new SecurityException("ChainId provided but session root does not exist."); - - chain = await kernel.GetChainAsync(context.ChainId.Value) ?? throw new SecurityException("Chain not found."); + chain = await kernel.GetChainAsync(context.ChainId.Value) + ?? throw new UAuthNotFoundException("Chain not found."); if (chain.IsRevoked) - throw new SecurityException("Chain is revoked."); + throw new UAuthValidationException("Chain revoked."); - if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) - throw new SecurityException("Chain does not belong to the current user/tenant."); + if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant) + throw new UAuthValidationException("Invalid chain ownership."); } else { - chain = UAuthSessionChain.Create( SessionChainId.New(), root.RootId, context.Tenant, context.UserKey, now, - null, + expiresAt, context.Device, ClaimsSnapshot.Empty, root.SecurityVersion ); await kernel.CreateChainAsync(chain); - - var updatedRoot = root.AttachChain(chain, now); - - if (isNewRoot) - { - await kernel.SaveRootAsync(updatedRoot, 0); - } - else - { - await kernel.SaveRootAsync(updatedRoot, rootExpectedVersion); - } - - root = updatedRoot; } var session = UAuthSession.Create( sessionId: sessionId, tenant: context.Tenant, userKey: context.UserKey, - chainId: SessionChainId.Unassigned, + chainId: chain.ChainId, now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, @@ -127,9 +106,8 @@ await kernel.ExecuteAsync(async _ => ); await kernel.CreateSessionAsync(session); - var bound = session.WithChain(chain.ChainId); - await kernel.SaveSessionAsync(bound, 0); - var updatedChain = chain.AttachSession(bound.SessionId, now); + + var updatedChain = chain.AttachSession(session.SessionId, now); await kernel.SaveChainAsync(updatedChain, chain.Version); issued = new IssuedSession @@ -141,7 +119,7 @@ await kernel.ExecuteAsync(async _ => }, ct); if (issued == null) - throw new InvalidCastException("Can't issued session."); + throw new InvalidCastException("Issue failed."); return issued; } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index da4ac522..c9c409f9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -31,9 +31,24 @@ public async Task> GetUserChainsAsync(Access { chains = request.SortBy switch { + nameof(SessionChainSummaryDto.ChainId) => + request.Descending + ? chains.OrderByDescending(x => x.ChainId).ToList() + : chains.OrderBy(x => x.Version).ToList(), + nameof(SessionChainSummaryDto.CreatedAt) => request.Descending - ? chains.OrderByDescending(x => x.Version).ToList() + ? chains.OrderByDescending(x => x.CreatedAt).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.DeviceType) => + request.Descending + ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.Platform) => + request.Descending + ? chains.OrderByDescending(x => x.Device.Platform).ToList() : chains.OrderBy(x => x.Version).ToList(), nameof(SessionChainSummaryDto.RotationCount) => @@ -53,12 +68,16 @@ public async Task> GetUserChainsAsync(Access .Select(c => new SessionChainSummaryDto { ChainId = c.ChainId, - DeviceName = null, - DeviceType = null, - CreatedAt = DateTimeOffset.MinValue, - LastSeenAt = null, + DeviceType = c.Device.DeviceType, + OperatingSystem = c.Device.OperatingSystem, + Platform = c.Device.Platform, + Browser = c.Device.Browser, + CreatedAt = c.CreatedAt, + LastSeenAt = c.LastSeenAt, RotationCount = c.RotationCount, + TouchCount = c.TouchCount, IsRevoked = c.IsRevoked, + RevokedAt = c.RevokedAt, ActiveSessionId = c.ActiveSessionId, IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value }) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index 33d4a48f..da89b7c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -4,7 +4,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace CodeBeam.UltimateAuth.Server.Services; @@ -14,10 +13,7 @@ internal sealed class UAuthSessionValidator : ISessionValidator private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; - public UAuthSessionValidator( - ISessionStoreFactory storeFactory, - IUserClaimsProvider claimsProvider, - IOptions options) + public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { _storeFactory = storeFactory; _claimsProvider = claimsProvider; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index 22a3e1fb..b7f0977d 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -38,23 +38,17 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(x => x.UserKey) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.UserKey }) - .IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); e.Property(x => x.SecurityVersion) .IsRequired(); - e.Property(x => x.LastUpdatedAt) - .IsRequired(); - e.Property(x => x.RootId) .HasConversion( v => v.Value, v => SessionRootId.From(v)) .IsRequired(); - - e.HasIndex(x => new { x.Tenant, x.RootId }); - }); b.Entity(e => diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index af1b2aac..5d1e613f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -10,11 +10,12 @@ internal sealed class SessionRootProjection public TenantKey Tenant { get; set; } public UserKey UserKey { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersion { get; set; } - public DateTimeOffset LastUpdatedAt { get; set; } - public long Version { get; set; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index f0e2627e..63d1fa41 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -4,17 +4,17 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal static class SessionRootProjectionMapper { - public static UAuthSessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList? chains = null) + public static UAuthSessionRoot ToDomain(this SessionRootProjection root) { return UAuthSessionRoot.FromProjection( root.RootId, root.Tenant, root.UserKey, + root.CreatedAt, + root.UpdatedAt, root.IsRevoked, root.RevokedAt, root.SecurityVersion, - chains ?? Array.Empty(), - root.LastUpdatedAt, root.Version ); } @@ -27,11 +27,13 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) Tenant = root.Tenant, UserKey = root.UserKey, + CreatedAt = root.CreatedAt, + UpdatedAt = root.UpdatedAt, + IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, - LastUpdatedAt = root.LastUpdatedAt, Version = root.Version }; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 74979da7..7ef170f7 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -228,7 +228,7 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId .Where(x => x.UserKey == userKey) .ToListAsync(); - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + return rootProjection.ToDomain(); } public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) @@ -279,11 +279,29 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) .SingleOrDefaultAsync(); } - public async Task> GetChainsByUserAsync(UserKey userKey) + public async Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false) + { + var rootsQuery = _db.Roots.AsNoTracking().Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + rootsQuery = rootsQuery.Where(r => !r.IsRevoked); + } + + var rootIds = await rootsQuery.Select(r => r.RootId).ToListAsync(); + + if (rootIds.Count == 0) + return Array.Empty(); + + var projections = await _db.Chains.AsNoTracking().Where(c => rootIds.Contains(c.RootId)).ToListAsync(); + return projections.Select(c => c.ToDomain()).ToList(); + } + + public async Task> GetChainsByRootAsync(SessionRootId rootId) { var projections = await _db.Chains .AsNoTracking() - .Where(x => x.UserKey == userKey) + .Where(x => x.RootId == rootId) .ToListAsync(); return projections.Select(x => x.ToDomain()).ToList(); @@ -313,7 +331,7 @@ public async Task> GetSessionsByChainAsync(SessionCh .Where(x => x.RootId == rootId) .ToListAsync(); - return rootProjection.ToDomain(chains.Select(c => c.ToDomain()).ToList()); + return rootProjection.ToDomain(); } public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 273c755e..6baab3dc 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -123,15 +123,6 @@ public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) return Task.CompletedTask; } - //public Task GetActiveSessionIdAsync(SessionChainId chainId) - // => Task.FromResult(_activeSessions.TryGetValue(chainId, out var id) ? id : null); - - //public Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) - //{ - // _activeSessions[chainId] = sessionId; - // return Task.CompletedTask; - //} - public Task GetRootByUserAsync(UserKey userKey) => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); @@ -183,12 +174,25 @@ public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) return Task.FromResult(null); } - public Task> GetChainsByUserAsync(UserKey userKey) + public Task> GetChainsByUserAsync(UserKey userKey,bool includeHistoricalRoots = false) { - if (!_roots.TryGetValue(userKey, out var root)) - return Task.FromResult>(Array.Empty()); + var roots = _roots.Values.Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + roots = roots.Where(r => !r.IsRevoked); + } - return Task.FromResult>(root.Chains.ToList()); + var rootIds = roots.Select(r => r.RootId).ToHashSet(); + + var result = _chains.Values.Where(c => rootIds.Contains(c.RootId)).ToList().AsReadOnly(); + return Task.FromResult>(result); + } + + public Task> GetChainsByRootAsync(SessionRootId rootId) + { + var result = _chains.Values.Where(c => c.RootId == rootId).ToList().AsReadOnly(); + return Task.FromResult>(result); } public Task> GetSessionsByChainAsync(SessionChainId chainId) @@ -234,9 +238,7 @@ public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) _chains[chainId] = revokedChain; } - var sessions = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); foreach (var session in sessions) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 952588b5..3e967fce 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -36,7 +36,7 @@ public async Task Invalid_When_Token_Not_Found() Tenant = TenantKey.Single, RefreshToken = "non-existing", Now = DateTimeOffset.UtcNow, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -73,7 +73,7 @@ public async Task Reuse_Detected_When_Token_is_Revoked() Tenant = TenantKey.Single, RefreshToken = rawToken, Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); @@ -106,7 +106,7 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() RefreshToken = "hash-2", ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), Now = now, - Device = DeviceContext.FromDeviceId(DeviceId.Create(ValidDeviceId)), + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), }); Assert.False(result.IsValid); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs index 7265c154..87daa2e2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class TestDevice { - public static DeviceContext Default() => DeviceContext.FromDeviceId(DeviceId.Create("test-device-000-000-000-000-01")); + public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 604465d5..10f97f37 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -26,7 +26,7 @@ public async Task Successful_login_should_return_success_result() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeTrue(); @@ -45,7 +45,7 @@ public async Task Successful_login_should_create_session() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); result.SessionId.Should().NotBeNull(); @@ -68,7 +68,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var store = runtime.Services.GetRequiredService(); @@ -95,7 +95,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); await orchestrator.LoginAsync(flow, @@ -104,7 +104,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "user", // valid password - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var store = runtime.Services.GetRequiredService(); @@ -126,7 +126,7 @@ public async Task Invalid_password_should_fail_login() Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -145,7 +145,7 @@ public async Task Non_existent_user_should_fail_login_gracefully() Tenant = TenantKey.Single, Identifier = "ghost", Secret = "whatever", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -168,7 +168,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var store = runtime.Services.GetRequiredService(); @@ -195,7 +195,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); // try again with correct password @@ -205,7 +205,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -228,7 +228,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var store = runtime.Services.GetRequiredService(); @@ -240,7 +240,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var state2 = store.GetState(TenantKey.Single, TestUsers.User); @@ -267,7 +267,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); } @@ -278,25 +278,6 @@ await orchestrator.LoginAsync(flow, state.FailedLoginAttempts.Should().Be(5); } - [Fact] - public async Task Invalid_device_id_should_throw_security_exception() - { - var runtime = new TestAuthRuntime(); - var orchestrator = runtime.GetLoginOrchestrator(); - var flow = await runtime.CreateLoginFlowAsync(); - - Func act = () => orchestrator.LoginAsync(flow, - new LoginRequest - { - Tenant = TenantKey.Single, - Identifier = "user", - Secret = "user", - Device = DeviceContext.FromDeviceId(DeviceId.Create("x")), // too short - }); - - await act.Should().ThrowAsync(); - } - [Fact] public async Task Locked_user_failed_login_should_not_extend_lockout_duration() { @@ -315,7 +296,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var store = runtime.Services.GetRequiredService(); @@ -329,7 +310,7 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - Device = TestDevice.Default(), + //Device = TestDevice.Default(), }); var state2 = store.GetState(TenantKey.Single, TestUsers.User); @@ -358,7 +339,7 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() + //Device = TestDevice.Default() }); captured.Should().NotBeNull(); @@ -387,7 +368,7 @@ public async Task Login_success_should_trigger_OnAnyEvent() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() + //Device = TestDevice.Default() }); count.Should().BeGreaterThan(0); @@ -409,7 +390,7 @@ public async Task Event_handler_exception_should_not_break_login_flow() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - Device = TestDevice.Default() + //Device = TestDevice.Default() }); result.IsSuccess.Should().BeTrue(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index f6f52e60..a129abe1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -69,7 +69,7 @@ await identifierService.AddUserIdentifierAsync(context, Tenant = TenantKey.Single, Identifier = "+905551111111", Secret = "user", - Device = TestDevice.Default() + //Device = TestDevice.Default() }); result.IsSuccess.Should().BeFalse(); From a9a4412caeb43e5d196bc4296688ee5a388379be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 1 Mar 2026 22:30:55 +0300 Subject: [PATCH 10/29] Added State Clear on Current Chain Revoke --- .../Components/Dialogs/SessionDialog.razor | 7 +++++ .../Abstractions/ISessionEvents.cs | 12 ++++++++ .../Authentication/IUAuthStateManager.cs | 5 ++++ .../Authentication/UAuthStateManager.cs | 27 +++++++++++++++-- .../Device/BrowserDeviceIdStorage.cs | 13 +++++++-- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Infrastructure/SessionEvents.cs | 13 +++++++++ .../Services/ISessionClient.cs | 2 +- .../Services/UAuthSessionClient.cs | 29 +++++++++++++++---- .../Contracts/Revoke/RevokeResult.cs | 8 +++++ .../Endpoints/SessionEndpointHandler.cs | 8 ++--- .../Services/ISessionApplicationService.cs | 2 +- .../Services/SessionApplicationService.cs | 13 +++++++-- .../Client/UAuthStateManagerTests.cs | 10 +++++-- 14 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor index e712445a..2fb8108d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -3,6 +3,7 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IUAuthStateManager StateManager @inject NavigationManager Nav @@ -179,6 +180,12 @@ if (result.IsSuccess) { Snackbar.Add("Logged out of device", Severity.Success); + + if (result?.Value?.CurrentSessionRevoked == true) + { + Nav.NavigateTo("/login"); + return; + } await ReloadAsync(); } else diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs new file mode 100644 index 00000000..3c58ad56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Client.Abstractions; + +public interface ISessionEvents +{ + /// + /// Fired when the current session becomes invalid + /// due to revoke or security mismatch. + /// + event Action? CurrentSessionRevoked; + + void RaiseCurrentSessionRevoked(); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs index f30e0447..c5c28a5c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs @@ -32,4 +32,9 @@ public interface IUAuthStateManager /// Forces state to be cleared and re-validation required. /// void MarkStale(); + + /// + /// Removes all authentication state information managed by the implementation. + /// + void Clear(); } diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs index 0751651d..9a8195bb 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs @@ -1,18 +1,23 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; namespace CodeBeam.UltimateAuth.Client.Authentication; -internal sealed class UAuthStateManager : IUAuthStateManager +internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable { private readonly IUAuthClient _client; + private readonly ISessionEvents _events; private readonly IClock _clock; public UAuthState State { get; } = UAuthState.Anonymous(); - public UAuthStateManager(IUAuthClient client, IClock clock) + public UAuthStateManager(IUAuthClient client, ISessionEvents events, IClock clock) { _client = client; + _events = events; _clock = clock; + + _events.CurrentSessionRevoked += OnCurrentSessionRevoked; } public async Task EnsureAsync(bool force = false, CancellationToken ct = default) @@ -54,5 +59,21 @@ public void MarkStale() State.MarkStale(); } + public void Clear() + { + State.Clear(); + } + + private void OnCurrentSessionRevoked() + { + if (State.IsAuthenticated) + Clear(); + } + + public void Dispose() + { + _events.CurrentSessionRevoked -= OnCurrentSessionRevoked; + } + public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; } diff --git a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs index 2c529c99..e2173571 100644 --- a/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs +++ b/src/CodeBeam.UltimateAuth.Client/Device/BrowserDeviceIdStorage.cs @@ -15,10 +15,17 @@ public BrowserDeviceIdStorage(IBrowserStorage storage) public async ValueTask LoadAsync(CancellationToken ct = default) { - if (!await _storage.ExistsAsync(StorageScope.Local, Key)) - return null; + try + { + if (!await _storage.ExistsAsync(StorageScope.Local, Key)) + return null; - return await _storage.GetAsync(StorageScope.Local, Key); + return await _storage.GetAsync(StorageScope.Local, Key); + } + catch (TaskCanceledException) + { + return null; + } } public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index 31975e8a..ff12fac5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -66,6 +66,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddSingleton(); services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); + services.AddSingleton(); services.PostConfigure(o => { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs new file mode 100644 index 00000000..1494b29e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Client.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class SessionEvents : ISessionEvents +{ + public event Action? CurrentSessionRevoked; + + public void RaiseCurrentSessionRevoked() + { + CurrentSessionRevoked?.Invoke(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs index d7f0c327..30824b9a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs @@ -7,7 +7,7 @@ public interface ISessionClient { Task>> GetMyChainsAsync(PageRequest? request = null); Task> GetMyChainDetailAsync(SessionChainId chainId); - Task RevokeMyChainAsync(SessionChainId chainId); + Task> RevokeMyChainAsync(SessionChainId chainId); Task RevokeMyOtherChainsAsync(); Task RevokeAllMyChainsAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index bb66f333..d6e2b0ab 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,11 +11,13 @@ internal sealed class UAuthSessionClient : ISessionClient { private readonly IUAuthRequestClient _request; private readonly UAuthClientOptions _options; + private readonly ISessionEvents _events; - public UAuthSessionClient(IUAuthRequestClient request, IOptions options) + public UAuthSessionClient(IUAuthRequestClient request, IOptions options, ISessionEvents events) { _request = request; _options = options.Value; + _events = events; } private string Url(string path) @@ -34,10 +37,17 @@ public async Task> GetMyChainDetailAsync(Sess return UAuthResultMapper.FromJson(raw); } - public async Task RevokeMyChainAsync(SessionChainId chainId) + public async Task> RevokeMyChainAsync(SessionChainId chainId) { - var raw = await _request.SendFormAsync(Url($"/session/me/chains/{chainId}/revoke")); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/session/me/chains/{chainId}/revoke")); + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentSessionRevoked == true) + { + _events.RaiseCurrentSessionRevoked(); + } + + return result; } public async Task RevokeMyOtherChainsAsync() @@ -49,7 +59,14 @@ public async Task RevokeMyOtherChainsAsync() public async Task RevokeAllMyChainsAsync() { var raw = await _request.SendFormAsync(Url("/session/me/revoke-all")); - return UAuthResultMapper.From(raw); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + _events.RaiseCurrentSessionRevoked(); + } + + return result; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs new file mode 100644 index 00000000..1c3f2797 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record RevokeResult + { + public bool CurrentSessionRevoked { get; init; } + public bool RootRevoked { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs index c6cfb3fa..d017741e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs @@ -67,8 +67,8 @@ public async Task RevokeMyChainAsync(SessionChainId chainId, HttpContex resource: "sessions", resourceId: flow.UserKey?.Value); - await _sessions.RevokeUserChainAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); - return Results.Ok(); + var result = await _sessions.RevokeUserChainAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); } public async Task RevokeOtherChainsAsync(HttpContext ctx) @@ -166,8 +166,8 @@ public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId resource: "sessions", resourceId: userKey.Value); - await _sessions.RevokeUserChainAsync(access, userKey, chainId, ctx.RequestAborted); - return Results.Ok(); + var result = await _sessions.RevokeUserChainAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); } public async Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs index 1df432b8..d1d2f178 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs @@ -11,7 +11,7 @@ public interface ISessionApplicationService Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default); - Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index c9c409f9..e0ad5274 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -139,15 +139,22 @@ public async Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + public async Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { + var isCurrent = context.ActorChainId == chainId; var store = _storeFactory.Create(context.ResourceTenant); await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow); + + return new RevokeResult + { + CurrentSessionRevoked = isCurrent, + RootRevoked = false + }; }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); + return await _accessOrchestrator.ExecuteAsync(context, command, ct); } public async Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index 2fd7b759..32805066 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Authentication; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Abstractions; @@ -37,10 +38,11 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta var client = new Mock(); client.Setup(x => x.Flows).Returns(flowClient.Object); + var sessionEvents = new Mock(); var clock = new Mock(); clock.Setup(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); - var manager = new UAuthStateManager(client.Object, clock.Object); + var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); await manager.EnsureAsync(); await manager.EnsureAsync(); @@ -52,6 +54,7 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta public async Task EnsureAsync_force_should_always_validate() { var client = new Mock(); + var sessionEvents = new Mock(); var clock = new Mock(); client.Setup(x => x.Flows.ValidateAsync()) @@ -60,7 +63,7 @@ public async Task EnsureAsync_force_should_always_validate() State = SessionState.Invalid }); - var manager = new UAuthStateManager(client.Object, clock.Object); + var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); await manager.EnsureAsync(force: true); await manager.EnsureAsync(force: true); @@ -72,6 +75,7 @@ public async Task EnsureAsync_force_should_always_validate() public async Task EnsureAsync_invalid_should_clear_state() { var client = new Mock(); + var sessionEvents = new Mock(); var clock = new Mock(); client.Setup(x => x.Flows.ValidateAsync()) @@ -80,7 +84,7 @@ public async Task EnsureAsync_invalid_should_clear_state() State = SessionState.Invalid }); - var manager = new UAuthStateManager(client.Object, clock.Object); + var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); await manager.EnsureAsync(); From d4586393090f9a41b783c051aacb97743f2f1b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Mar 2026 14:59:39 +0300 Subject: [PATCH 11/29] Credential Enhancement --- .../Defaults/UAuthActions.cs | 1 - .../ICredentialEndpointHandler.cs | 17 +- .../Endpoints/UAuthEndpointRegistrar.cs | 35 +-- .../Orchestrator/AccessCommand.cs | 4 +- .../Dtos/CredentialDto.cs | 1 + .../Dtos/CredentialSecurityState.cs | 196 +++++++++--- .../Request/BeginCredentialResetRequest.cs | 3 +- .../Request/ChangeCredentialRequest.cs | 7 +- .../Request/CompleteCredentialResetRequest.cs | 3 +- .../Request/CredentialActionRequest.cs | 7 + .../Request/DeleteCredentialRequest.cs | 9 + .../Request/ResetPasswordRequest.cs | 6 +- .../Request/RevokeCredentialRequest.cs | 2 + .../Responses/AddCredentialResult.cs | 11 +- .../InMemoryCredentialSeedContributor.cs | 41 ++- .../InMemoryCredentialStore.cs | 139 +++------ .../Domain/PasswordCredential.cs | 50 ++- .../Endpoints/CredentialEndpointHandler.cs | 94 ++---- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../Services/CredentialManagementService.cs | 244 +++++++++++++++ .../ICredentialAuthenticationService.cs | 18 ++ .../Services/ICredentialManagementService.cs | 21 ++ .../Services/IUserCredentialsService.cs | 23 -- .../Services/UserCredentialsService.cs | 284 ------------------ .../Abstractions/ICredentialDescriptor.cs | 1 + .../Abstractions/ICredentialStore.cs | 8 +- 26 files changed, 643 insertions(+), 586 deletions(-) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs index 9195fe83..4c7e28a7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -62,7 +62,6 @@ public static class Credentials public const string RevokeSelf = "credentials.revoke.self"; public const string RevokeAdmin = "credentials.revoke.admin"; public const string ActivateSelf = "credentials.activate.self"; - public const string ActivateAdmin = "credentials.activate.admin"; public const string BeginResetSelf = "credentials.beginreset.self"; public const string BeginResetAdmin = "credentials.beginreset.admin"; public const string CompleteResetSelf = "credentials.completereset.self"; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs index a788a7b5..50a318d2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -7,16 +7,15 @@ public interface ICredentialEndpointHandler { Task GetAllAsync(HttpContext ctx); Task AddAsync(HttpContext ctx); - Task ChangeAsync(string type, HttpContext ctx); - Task RevokeAsync(string type, HttpContext ctx); - Task BeginResetAsync(string type, HttpContext ctx); - Task CompleteResetAsync(string type, HttpContext ctx); + Task ChangeSecretAsync(HttpContext ctx); + Task RevokeAsync(HttpContext ctx); + Task BeginResetAsync(HttpContext ctx); + Task CompleteResetAsync(HttpContext ctx); Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); Task AddAdminAsync(UserKey userKey, HttpContext ctx); - Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx); - Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task RevokeAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteAdminAsync(UserKey userKey, HttpContext ctx); + Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx); + Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index e665fbeb..0cc93fba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -223,17 +223,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) @@ -242,20 +242,17 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - - adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } if (options.Endpoints.Authorization != false) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs index 07bdd410..69588e26 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -internal sealed class AccessCommand : IAccessCommand +public sealed class AccessCommand : IAccessCommand { private readonly Func _execute; @@ -12,7 +12,7 @@ public AccessCommand(Func execute) public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } -internal sealed class AccessCommand : IAccessCommand +public sealed class AccessCommand : IAccessCommand { private readonly Func> _execute; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index e236ba27..d9ccf974 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -2,6 +2,7 @@ public sealed record CredentialDto { + public Guid Id { get; set; } public CredentialType Type { get; init; } public CredentialSecurityStatus Status { get; init; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 2291404c..ffbd6ccb 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -1,13 +1,41 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { public DateTimeOffset? RevokedAt { get; } public DateTimeOffset? LockedUntil { get; } public DateTimeOffset? ExpiresAt { get; } - public DateTimeOffset? ResetRequestedAt { get; init; } + public DateTimeOffset? ResetRequestedAt { get; } + public DateTimeOffset? ResetExpiresAt { get; } + public DateTimeOffset? ResetConsumedAt { get; } + public int FailedAttemptCount { get; } + public DateTimeOffset? LastFailedAt { get; } public Guid SecurityStamp { get; } + public CredentialSecurityState( + DateTimeOffset? revokedAt = null, + DateTimeOffset? lockedUntil = null, + DateTimeOffset? expiresAt = null, + DateTimeOffset? resetRequestedAt = null, + DateTimeOffset? resetExpiresAt = null, + DateTimeOffset? resetConsumedAt = null, + int failedAttemptCount = 0, + DateTimeOffset? lastFailedAt = null, + Guid securityStamp = default) + { + RevokedAt = revokedAt; + LockedUntil = lockedUntil; + ExpiresAt = expiresAt; + ResetRequestedAt = resetRequestedAt; + ResetExpiresAt = resetExpiresAt; + ResetConsumedAt = resetConsumedAt; + FailedAttemptCount = failedAttemptCount; + LastFailedAt = lastFailedAt; + SecurityStamp = securityStamp; + } + public CredentialSecurityStatus Status(DateTimeOffset now) { if (RevokedAt is not null) @@ -20,25 +48,19 @@ public CredentialSecurityStatus Status(DateTimeOffset now) return CredentialSecurityStatus.Expired; if (ResetRequestedAt is not null) + { + if (ResetConsumedAt is not null) + return CredentialSecurityStatus.Active; + + if (ResetExpiresAt is not null && ResetExpiresAt <= now) + return CredentialSecurityStatus.Active; + return CredentialSecurityStatus.ResetRequested; + } return CredentialSecurityStatus.Active; } - public CredentialSecurityState( - DateTimeOffset? revokedAt = null, - DateTimeOffset? lockedUntil = null, - DateTimeOffset? expiresAt = null, - DateTimeOffset? resetRequestedAt = null, - Guid securityStamp = default) - { - RevokedAt = revokedAt; - LockedUntil = lockedUntil; - ExpiresAt = expiresAt; - ResetRequestedAt = resetRequestedAt; - SecurityStamp = securityStamp; - } - /// /// Determines whether the credential can be used at the given time. /// @@ -51,54 +73,158 @@ public static CredentialSecurityState Active(Guid? securityStamp = null) lockedUntil: null, expiresAt: null, resetRequestedAt: null, - securityStamp: securityStamp ?? Guid.NewGuid()); + resetExpiresAt: null, + resetConsumedAt: null, + failedAttemptCount: 0, + lastFailedAt: null, + securityStamp: securityStamp ?? Guid.NewGuid() + ); } + /// + /// Revokes the credential permanently. + /// public CredentialSecurityState Revoke(DateTimeOffset now) { + if (RevokedAt is not null) + return this; + return new CredentialSecurityState( revokedAt: now, lockedUntil: LockedUntil, expiresAt: ExpiresAt, resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + failedAttemptCount: FailedAttemptCount, + lastFailedAt: LastFailedAt, + securityStamp: Guid.NewGuid() + ); } + /// + /// Sets or clears expiry while preserving the rest of the state. + /// public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt) { + // optional: normalize already-expired value? keep as-is; domain policy can decide. + if (ExpiresAt == expiresAt) + return this; + return new CredentialSecurityState( revokedAt: RevokedAt, lockedUntil: LockedUntil, expiresAt: expiresAt, resetRequestedAt: ResetRequestedAt, - securityStamp: SecurityStamp); + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + failedAttemptCount: FailedAttemptCount, + lastFailedAt: LastFailedAt, + securityStamp: EnsureStamp(SecurityStamp) + ); } - public CredentialSecurityState BeginReset(DateTimeOffset now, bool rotateStamp = true) - => new( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: now, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp - ); - - public CredentialSecurityState CompleteReset(bool rotateStamp = true) - => new( + private static Guid EnsureStamp(Guid stamp) => stamp == Guid.Empty ? Guid.NewGuid() : stamp; + + public CredentialSecurityState RotateStamp() + { + return new CredentialSecurityState( revokedAt: RevokedAt, lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: null, - securityStamp: rotateStamp ? Guid.NewGuid() : SecurityStamp + resetRequestedAt: ResetRequestedAt, + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + failedAttemptCount: FailedAttemptCount, + lastFailedAt: LastFailedAt, + securityStamp: Guid.NewGuid() ); + } - public CredentialSecurityState RotateStamp() + public CredentialSecurityState RegisterSuccessfulAuthentication() { return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, + lockedUntil: null, + expiresAt: ExpiresAt, + resetRequestedAt: ResetRequestedAt, + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + failedAttemptCount: 0, + lastFailedAt: null, + securityStamp: EnsureStamp(SecurityStamp) + ); + } + + public CredentialSecurityState RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan lockoutDuration) + { + if (threshold <= 0) + throw new UAuthValidationException(nameof(threshold)); + + var failed = FailedAttemptCount + 1; + + var newLockedUntil = LockedUntil; + + if (failed >= threshold) + { + var candidate = now.Add(lockoutDuration); + + if (LockedUntil is null || candidate > LockedUntil) + newLockedUntil = candidate; + } + + return new CredentialSecurityState( + revokedAt: RevokedAt, + lockedUntil: newLockedUntil, expiresAt: ExpiresAt, resetRequestedAt: ResetRequestedAt, - securityStamp: Guid.NewGuid()); + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + failedAttemptCount: failed, + lastFailedAt: now, + securityStamp: EnsureStamp(SecurityStamp) + ); + } + + public CredentialSecurityState BeginReset(DateTimeOffset now, TimeSpan validity) + { + if (validity <= TimeSpan.Zero) + throw new UAuthValidationException("credential_lockout_threshold_invalid"); + + return new CredentialSecurityState( + revokedAt: RevokedAt, + lockedUntil: LockedUntil, + expiresAt: ExpiresAt, + resetRequestedAt: now, + resetExpiresAt: now.Add(validity), + resetConsumedAt: null, + failedAttemptCount: FailedAttemptCount, + lastFailedAt: LastFailedAt, + securityStamp: Guid.NewGuid() + ); + } + + public CredentialSecurityState CompleteReset(DateTimeOffset now, bool rotateStamp = true) + { + if (ResetRequestedAt is null) + throw new UAuthValidationException("reset_not_requested"); + + if (ResetConsumedAt is not null) + throw new UAuthValidationException("reset_already_consumed"); + + if (ResetExpiresAt is not null && ResetExpiresAt <= now) + throw new UAuthValidationException("reset_expired"); + + return new CredentialSecurityState( + revokedAt: RevokedAt, + lockedUntil: null, + expiresAt: ExpiresAt, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: now, + failedAttemptCount: 0, + lastFailedAt: null, + securityStamp: rotateStamp ? Guid.NewGuid() : EnsureStamp(SecurityStamp) + ); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs index 98eb2e4c..98574831 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -2,5 +2,6 @@ public sealed record BeginCredentialResetRequest { - public string? Reason { get; init; } + public Guid Id { get; init; } + public string? Channel { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs index 85c5770e..7483584c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,8 +2,7 @@ public sealed record ChangeCredentialRequest { - public CredentialType Type { get; init; } - - public string CurrentSecret { get; init; } = default!; - public string NewSecret { get; init; } = default!; + public Guid Id { get; init; } + public required string CurrentSecret { get; init; } + public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs index 7dcaf3da..b9158e81 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -2,6 +2,7 @@ public sealed record CompleteCredentialResetRequest { + public Guid Id { get; init; } + public string? ResetToken { get; init; } public required string NewSecret { get; init; } - public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs new file mode 100644 index 00000000..d0f3465f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CredentialActionRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialActionRequest +{ + public Guid Id { get; set; } + public string? Reason { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs new file mode 100644 index 00000000..b1bf31fa --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public class DeleteCredentialRequest +{ + public Guid Id { get; init; } + public DeleteMode Mode { get; set; } = DeleteMode.Soft; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs index a895d5e7..0f185532 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -1,10 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ResetPasswordRequest { - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; set; } public required string NewPassword { get; init; } /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs index 108fa25c..b6a64cd6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -2,6 +2,8 @@ public sealed record RevokeCredentialRequest { + public Guid Id { get; init; } + /// /// If specified, credential is revoked until this time. /// Null means permanent revocation. diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs index dd9f3daa..c8a2b5a1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -6,19 +6,24 @@ public sealed record AddCredentialResult public string? Error { get; init; } + public Guid? Id { get; set; } public CredentialType? Type { get; init; } - public static AddCredentialResult Success(CredentialType type) + public static AddCredentialResult Success(Guid id, CredentialType type) => new() { Succeeded = true, - Type = type + Id = id, + Type = type, + Error = null }; public static AddCredentialResult Fail(string error) => new() { Succeeded = false, - Error = error + Error = error, + Id = null, + Type = null }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 2598739f..d16f3493 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -1,14 +1,18 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.AspNetCore.DataProtection; namespace CodeBeam.UltimateAuth.Credentials.InMemory; internal sealed class InMemoryCredentialSeedContributor : ISeedContributor { + private static readonly Guid _adminPasswordId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); public int Order => 10; private readonly ICredentialStore _credentials; @@ -24,25 +28,30 @@ public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemory public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { - await SeedCredentialAsync(_ids.GetAdminUserId(), "admin", tenant, ct); - await SeedCredentialAsync(_ids.GetUserUserId(), "user", tenant, ct); + await SeedCredentialAsync(_ids.GetAdminUserId(), _adminPasswordId, "admin", tenant, ct); + await SeedCredentialAsync(_ids.GetUserUserId(), _userPasswordId, "user", tenant, ct); } - private async Task SeedCredentialAsync(UserKey userKey, string hash, TenantKey tenant, CancellationToken ct) + private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, string secretHash, TenantKey tenant, CancellationToken ct) { - if (await _credentials.ExistsAsync(tenant, userKey, CredentialType.Password, null, ct)) - return; - - await _credentials.AddAsync(tenant, - new PasswordCredential( - Guid.NewGuid(), + try + { + await _credentials.AddAsync( tenant, - userKey, - _hasher.Hash(hash), - CredentialSecurityState.Active(), - new CredentialMetadata(), - DateTimeOffset.UtcNow, - null), - ct); + new PasswordCredential( + credentialId, + tenant, + userKey, + _hasher.Hash(secretHash), + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow, + null), + ct); + } + catch (UAuthConflictException) + { + // already seeded + } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 263c365b..7598a8c8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; using System.Collections.Concurrent; @@ -10,67 +10,26 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; internal sealed class InMemoryCredentialStore : ICredentialStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, Guid Id), PasswordCredential> _byId = new(); - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), ConcurrentDictionary> _byUser = new(); - - private readonly IUAuthPasswordHasher _hasher; - - public InMemoryCredentialStore(IUAuthPasswordHasher hasher) - { - _hasher = hasher; - } + private readonly ConcurrentDictionary<(TenantKey, Guid), PasswordCredential> _store = new(); public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult>(Array.Empty()); - - var list = new List(ids.Count); + var result = _store.Values + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .Cast() + .ToArray(); - foreach (var id in ids.Keys) - { - if (_byId.TryGetValue((tenant, id), out var cred)) - { - list.Add(cred); - } - } - - return Task.FromResult>(list); + return Task.FromResult>(result); } public Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _byId.TryGetValue((tenant, credentialId), out var cred); - return Task.FromResult(cred); - } - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!_byUser.TryGetValue((tenant, userKey), out var ids) || ids.Count == 0) - return Task.FromResult(false); - - foreach (var id in ids.Keys) - { - if (!_byId.TryGetValue((tenant, id), out var cred)) - continue; - - if (cred.Type != type) - continue; - - if (secretHash is null) - return Task.FromResult(true); - - if (string.Equals(cred.SecretHash, secretHash, StringComparison.Ordinal)) - return Task.FromResult(true); - } - - return Task.FromResult(false); + _store.TryGetValue((tenant, credentialId), out var credential); + return Task.FromResult(credential); } public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) @@ -81,25 +40,15 @@ public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var id = pwd.Id == Guid.Empty ? Guid.NewGuid() : pwd.Id; - - var key = (tenant, id); - if (_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_already_exists"); - - if (pwd.Id == Guid.Empty) - throw new InvalidOperationException("credential_id_required"); - - if (!_byId.TryAdd(key, pwd)) - throw new InvalidOperationException("credential_already_exists"); + var key = (tenant, pwd.Id); - var userIndex = _byUser.GetOrAdd((tenant, pwd.UserKey), _ => new ConcurrentDictionary()); - userIndex.TryAdd(pwd.Id, 0); + if (!_store.TryAdd(key, pwd)) + throw new UAuthConflictException("credential_already_exists"); return Task.CompletedTask; } - public Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -108,70 +57,78 @@ public Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationTo var key = (tenant, pwd.Id); - if (!_byId.ContainsKey(key)) - throw new InvalidOperationException("credential_not_found"); + if (!_store.ContainsKey(key)) + throw new UAuthNotFoundException("credential_not_found"); + + if (pwd.Version != expectedVersion) + throw new UAuthConflictException("credential_version_conflict"); - _byId[key] = pwd; + _store[key] = pwd; return Task.CompletedTask; } - public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var key = (tenant, credentialId); - if (!_byId.TryGetValue(key, out var cred)) - throw new InvalidOperationException("credential_not_found"); + if (!_store.TryGetValue(key, out var credential)) + throw new UAuthNotFoundException("credential_not_found"); - if (cred.IsRevoked) + if (credential.Version != expectedVersion) + throw new UAuthConflictException("credential_version_conflict"); + + if (credential.IsRevoked) return Task.CompletedTask; - cred.Revoke(revokedAt); + credential.Revoke(revokedAt); return Task.CompletedTask; } - public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var key = (tenant, credentialId); - if (!_byId.TryGetValue(key, out var cred)) - return Task.CompletedTask; + if (!_store.TryGetValue(key, out var credential)) + throw new UAuthNotFoundException("credential_not_found"); + + if (credential.Version != expectedVersion) + throw new UAuthConflictException("credential_version_conflict"); if (mode == DeleteMode.Hard) { - _byId.TryRemove(key, out _); - - if (_byUser.TryGetValue((tenant, cred.UserKey), out var set)) - { - set.TryRemove(credentialId, out _); - } - + _store.TryRemove(key, out _); return Task.CompletedTask; } - if (!cred.IsRevoked) - cred.Revoke(now); + if (!credential.IsRevoked) + credential.Revoke(now); return Task.CompletedTask; } - public Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue((tenant, userKey), out var ids)) - return Task.CompletedTask; + var credentials = _store.Values.Where(c => c.Tenant == tenant && c.UserKey == userKey).ToList(); - foreach (var id in ids.Keys.ToList()) + foreach (var credential in credentials) { - DeleteAsync(tenant, id, mode, now, ct); + ct.ThrowIfCancellationRequested(); + + await DeleteAsync( + tenant, + credential.Id, + mode, + now, + credential.Version, + ct); } - - return Task.CompletedTask; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 0f63cce1..7ffc1558 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; @@ -13,7 +14,6 @@ public sealed class PasswordCredential : ISecretCredential, ICredentialDescripto public CredentialType Type => CredentialType.Password; public string SecretHash { get; private set; } - public CredentialSecurityState Security { get; private set; } public CredentialMetadata Metadata { get; private set; } @@ -23,7 +23,6 @@ public sealed class PasswordCredential : ISecretCredential, ICredentialDescripto public long Version { get; private set; } public bool IsRevoked => Security.RevokedAt is not null; - public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; public PasswordCredential( @@ -71,14 +70,20 @@ public static PasswordCredential Create( public void ChangeSecret(string newSecretHash, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newSecretHash)) - throw new ArgumentException("Secret hash cannot be empty.", nameof(newSecretHash)); + throw new UAuthValidationException("credential_secret_required"); if (IsRevoked) - throw new InvalidOperationException("Cannot change secret of a revoked credential."); + throw new UAuthConflictException("credential_revoked"); + + if (IsExpired(now)) + throw new UAuthConflictException("credential_expired"); + + if (SecretHash == newSecretHash) + throw new UAuthValidationException("credential_secret_same"); SecretHash = newSecretHash; - UpdatedAt = now; Security = Security.RotateStamp(); + UpdatedAt = now; Version++; } @@ -89,18 +94,41 @@ public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) Version++; } - public void UpdateSecurity(CredentialSecurityState security, DateTimeOffset now) + public void Revoke(DateTimeOffset now) { - Security = security; + if (IsRevoked) + return; + + Security = Security.Revoke(now); UpdatedAt = now; Version++; } - public void Revoke(DateTimeOffset now) + public void RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan duration) { - if (IsRevoked) - return; - Security = Security.Revoke(now); + Security = Security.RegisterFailedAttempt(now, threshold, duration); + UpdatedAt = now; + Version++; + } + + public void RegisterSuccessfulAuthentication(DateTimeOffset now) + { + Security = Security.RegisterSuccessfulAuthentication(); + UpdatedAt = now; + Version++; + } + + public void BeginReset(DateTimeOffset now, TimeSpan validity) + { + Security = Security.BeginReset(now, validity); + UpdatedAt = now; + Version++; + } + + public void CompleteReset(DateTimeOffset now) + { + Security = Security.CompleteReset(now); + UpdatedAt = now; Version++; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 10b8092f..742950b7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -12,9 +12,9 @@ public sealed class CredentialEndpointHandler : ICredentialEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserCredentialsService _credentials; + private readonly ICredentialManagementService _credentials; - public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserCredentialsService credentials) + public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ICredentialManagementService credentials) { _authFlow = authFlow; _accessContextFactory = accessContextFactory; @@ -53,14 +53,11 @@ public async Task AddAsync(HttpContext ctx) return Results.Ok(result); } - public async Task ChangeAsync(string type, HttpContext ctx) + public async Task ChangeSecretAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -69,20 +66,15 @@ public async Task ChangeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - var result = await _credentials.ChangeAsync( - accessContext, credentialType, request, ctx.RequestAborted); - + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(result); } - public async Task RevokeAsync(string type, HttpContext ctx) + public async Task RevokeAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -91,18 +83,15 @@ public async Task RevokeAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAsync(string type, HttpContext ctx) + public async Task BeginResetAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -111,18 +100,15 @@ public async Task BeginResetAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task CompleteResetAsync(string type, HttpContext ctx) + public async Task CompleteResetAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -131,7 +117,7 @@ public async Task CompleteResetAsync(string type, HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } @@ -170,15 +156,12 @@ public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) return Results.Ok(result); } - public async Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -187,39 +170,18 @@ public async Task RevokeAdminAsync(UserKey userKey, string type, HttpCo resource: "credentials", resourceId: userKey.Value); - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) return Results.Unauthorized(); - if (!TryParseType(type, out var credentialType, out var error)) - return error!; - - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.ActivateAdmin, - resource: "credentials", - resourceId: userKey.Value); - - await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); - - return Results.NoContent(); - } - - public async Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx) - { - var flow = _authFlow.Current; - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - if (!TryParseType(type, out var credentialType, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -227,19 +189,16 @@ public async Task DeleteAdminAsync(UserKey userKey, string type, HttpCo resource: "credentials", resourceId: userKey.Value); - await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -248,18 +207,15 @@ public async Task BeginResetAdminAsync(UserKey userKey, string type, Ht resource: "credentials", resourceId: userKey.Value); - await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } - public async Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - if (!TryParseType(type, out var credentialType, out error)) - return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( @@ -268,7 +224,7 @@ public async Task CompleteResetAdminAsync(UserKey userKey, string type, resource: "credentials", resourceId: userKey.Value); - await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); return Results.NoContent(); } @@ -284,16 +240,4 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) error = null; return true; } - - private static bool TryParseType(string type, out CredentialType credentialType, out IResult? error) - { - if (!CredentialTypeParser.TryParse(type, out credentialType)) - { - error = Results.BadRequest($"Unsupported credential type: {type}"); - return false; - } - - error = null; - return true; - } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 731fb046..12497321 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -10,8 +10,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs new file mode 100644 index 00000000..c39578a4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -0,0 +1,244 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ICredentialStore _credentials; + private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; + + public CredentialManagementService( + IAccessOrchestrator accessOrchestrator, + ICredentialStore credentials, + IUAuthPasswordHasher hasher, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _credentials = credentials; + _hasher = hasher; + _clock = clock; + } + + public async Task GetAllAsync( + AccessContext context, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + + var dtos = credentials + .OfType() + .Select(c => new CredentialDto + { + Id = c.Id, + Type = c.Type, + Status = c.Security.Status(now), + LockedUntil = c.Security.LockedUntil, + ExpiresAt = c.Security.ExpiresAt, + RevokedAt = c.Security.RevokedAt, + ResetRequestedAt = c.Security.ResetRequestedAt, + LastUsedAt = c.Metadata.LastUsedAt, + Source = c.Metadata.Source + }) + .ToArray(); + + return new GetCredentialsResult { Credentials = dtos }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var hash = _hasher.Hash(request.Secret); + + var credential = PasswordCredentialFactory.Create( + tenant: context.ResourceTenant, + userKey: subjectUser, + secretHash: hash, + source: request.Source, + now: now); + + await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); + + return AddCredentialResult.Success(credential.Id, credential.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + + if (credential is not PasswordCredential pwd) + return ChangeCredentialResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return ChangeCredentialResult.Fail("credential_not_found"); + + var verified = _hasher.Verify(pwd.SecretHash, request.CurrentSecret); + if (!verified) + return ChangeCredentialResult.Fail("invalid_credentials"); + + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + pwd.ChangeSecret(newHash, now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + + return ChangeCredentialResult.Success(credential.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + pwd.Revoke(now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + //pwd.BeginReset(now, request.Validity); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + + pwd.CompleteReset(now); + + var hash = _hasher.Hash(request.NewSecret); + pwd.ChangeSecret(hash, now); + await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + await _credentials.DeleteAsync(context.ResourceTenant, pwd.Id, request.Mode, now, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------------------------------- + // INTERNAL ONLY - NEVER CALL THEM DIRECTLY + // ---------------------------------------- + async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); + return CredentialActionResult.Success(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs new file mode 100644 index 00000000..b4ab7ec1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +/// +/// Orchestrates an authentication attempt against a credential type. +/// Responsible for applying lockout policy by mutating the credential aggregate +/// and persisting it. +/// +public interface ICredentialAuthenticationService +{ + //Task AuthenticateAsync( + // AccessContext context, + // CredentialType type, + // CredentialAuthenticationRequest request, + // CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs new file mode 100644 index 00000000..0e6f2651 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface ICredentialManagementService +{ + Task GetAllAsync(AccessContext context, CancellationToken ct = default); + + Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); + + Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default); + + Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); + + Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs deleted file mode 100644 index eaddd57a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -public interface IUserCredentialsService -{ - Task GetAllAsync(AccessContext context, CancellationToken ct = default); - - Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); - - Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default); - - Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default); - - Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); - - Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct = default); - - Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct = default); - - Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs deleted file mode 100644 index d74955b5..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/UserCredentialsService.cs +++ /dev/null @@ -1,284 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference.Internal; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class UserCredentialsService : IUserCredentialsService, IUserCredentialsInternalService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly ICredentialStore _credentials; - private readonly IUAuthPasswordHasher _hasher; - private readonly IClock _clock; - - public UserCredentialsService( - IAccessOrchestrator accessOrchestrator, - ICredentialStore credentials, - IUAuthPasswordHasher hasher, - IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _credentials = credentials; - _hasher = hasher; - _clock = clock; - } - - public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new GetAllCredentialsCommand( - async innerCt => - { - if (context.ActorUserKey is not UserKey userKey) - throw new UnauthorizedAccessException(); - - var creds = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); - - var dtos = creds - .OfType() - .Select(c => new CredentialDto { - Type = c.Type, - Status = c.Security.Status(_clock.UtcNow), - LastUsedAt = c.Metadata.LastUsedAt, - LockedUntil = c.Security.LockedUntil, - ExpiresAt = c.Security.ExpiresAt, - RevokedAt = c.Security.RevokedAt, - ResetRequestedAt = c.Security.ResetRequestedAt, - Source = c.Metadata.Source}) - .ToArray(); - - return new GetCredentialsResult - { - Credentials = dtos - }; - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new AddCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var alreadyHasType = (await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt)) - .OfType() - .Any(c => c.Type == request.Type); - - if (alreadyHasType) - return AddCredentialResult.Fail("credential_already_exists"); - - var hash = _hasher.Hash(request.Secret); - - var credential = PasswordCredentialFactory.Create( - tenant: context.ResourceTenant, - userKey: userKey, - secretHash: hash, - source: request.Source, - now: now); - - await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); - - return AddCredentialResult.Success(request.Type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - public async Task ChangeAsync(AccessContext context, CredentialType type, ChangeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ChangeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return ChangeCredentialResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - } - else - { - return ChangeCredentialResult.Fail("credential_type_unsupported"); - } - - return ChangeCredentialResult.Success(type); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - - // ---------------- REVOKE ---------------- - - public async Task RevokeAsync(AccessContext context, CredentialType type, RevokeCredentialRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new RevokeCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.RevokeAsync(context.ResourceTenant, GetId(cred), now, innerCt); - return CredentialActionResult.Success(); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new ActivateCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is ICredentialDescriptor desc && cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(CredentialSecurityState.Active(pwd.Security.SecurityStamp), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct) - { - var cmd = new BeginCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - pwd.UpdateSecurity(pwd.Security.BeginReset(now), now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct) - { - var cmd = new CompleteCredentialResetCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - if (cred is PasswordCredential pwd) - { - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - pwd.UpdateSecurity(pwd.Security.CompleteReset(), now); - - await _credentials.UpdateAsync(context.ResourceTenant, pwd, innerCt); - return CredentialActionResult.Success(); - } - - return CredentialActionResult.Fail("credential_type_unsupported"); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var cmd = new DeleteCredentialCommand(async innerCt => - { - var userKey = EnsureActor(context); - var now = _clock.UtcNow; - - var cred = await GetSingleByTypeAsync(context.ResourceTenant, userKey, type, innerCt); - if (cred is null) - return CredentialActionResult.Fail("credential_not_found"); - - await _credentials.DeleteAsync( - tenant: context.ResourceTenant, - credentialId: GetId(cred), - mode: DeleteMode.Soft, - now: now, - ct: innerCt); - - return CredentialActionResult.Success(); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - // ---------------------------------------- - // INTERNAL ONLY - NEVER CALL THEM DIRECTLY - // ---------------------------------------- - async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - await _credentials.DeleteByUserAsync(tenant, userKey, DeleteMode.Soft, _clock.UtcNow, ct); - return CredentialActionResult.Success(); - } - - - private static UserKey EnsureActor(AccessContext context) - => context.ActorUserKey is UserKey uk ? uk : throw new UnauthorizedAccessException(); - - private static Guid GetId(ICredential c) - => c switch - { - PasswordCredential p => p.Id, - _ => throw new NotSupportedException("credential_id_missing") - }; - - private async Task GetSingleByTypeAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct) - { - var creds = await _credentials.GetByUserAsync(tenant, userKey, ct); - - var found = creds.OfType().FirstOrDefault(x => x.Type == type); - - return found as ICredential; - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index c8a4af55..63deb9ca 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -4,6 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialDescriptor { + Guid Id { get; } CredentialType Type { get; } CredentialSecurityState Security { get; } CredentialMetadata Metadata { get; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index e55c42b6..670c1d2b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; @@ -10,9 +9,8 @@ public interface ICredentialStore Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default); Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task UpdateAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedVersion, CancellationToken ct = default); + Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); - Task ExistsAsync(TenantKey tenant, UserKey userKey, CredentialType type, string? secretHash, CancellationToken ct = default); } From 342df0925b48fc32d1dbfa77cf6cd201b75c4eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 2 Mar 2026 23:20:25 +0300 Subject: [PATCH 12/29] Support Credential Level Lockout --- UltimateAuth.slnx | 1 + ...deBeam.UltimateAuth.Sample.UAuthHub.csproj | 1 + .../Program.cs | 4 +- ...am.UltimateAuth.Sample.BlazorServer.csproj | 1 + .../Program.cs | 2 + .../IAuthenticationSecurityManager.cs | 14 + .../IAuthenticationSecurityStateStore.cs | 14 + .../Contracts/Login/LoginRequest.cs | 1 + .../{CredentialKind.cs => GrantKind.cs} | 2 +- ...yCredentialKind.cs => PrimaryGrantKind.cs} | 2 +- .../Security/AuthenticationSecurityScope.cs | 7 + .../Security/AuthenticationSecurityState.cs | 239 ++++++++++++++++++ .../Domain/Security}/CredentialType.cs | 2 +- .../Options/UAuthLoginOptions.cs | 15 +- .../Abstractions/ICredentialResponseWriter.cs | 6 +- .../IPrimaryCredentialResolver.cs | 2 +- ...AuthResponseOptionsModeTemplateResolver.cs | 24 +- .../Auth/Response/AuthResponseResolver.cs | 6 +- .../ClientProfileAuthResponseAdapter.cs | 8 +- .../UAuthAuthenticationExtension.cs | 0 .../UAuthAuthenticationHandler.cs | 0 .../UAuthAuthenticationSchemeOptions.cs | 0 .../AuthenticationSecurityManager.cs | 104 ++++++++ .../Contracts/ResolvedCredential.cs | 2 +- .../Endpoints/LoginEndpointHandler.cs | 6 +- .../Endpoints/PkceEndpointHandler.cs | 6 +- .../Endpoints/RefreshEndpointHandler.cs | 10 +- .../Endpoints/ValidateEndpointHandler.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Flows/Login/LoginAuthority.cs | 2 +- .../Flows/Login/LoginDecisionContext.cs | 5 +- .../Flows/Login/LoginOrchestrator.cs | 96 +++---- .../Flows/Refresh/IRefreshResponsePolicy.cs | 2 +- .../Flows/Refresh/RefreshResponsePolicy.cs | 14 +- .../Cookies/IUAuthCookiePolicyBuilder.cs | 2 +- .../Cookies/UAuthCookiePolicyBuilder.cs | 12 +- .../Credentials/CredentialResponseWriter.cs | 18 +- .../Credentials/FlowCredentialResolver.cs | 8 +- .../Credentials/PrimaryCredentialResolver.cs | 2 +- .../Options/CredentialResponseOptions.cs | 4 +- .../Options/UAuthPrimaryCredentialPolicy.cs | 4 +- .../AssemblyVisibility.cs | 3 + ...ltimateAuth.Authentication.InMemory.csproj | 16 ++ ...nMemoryAuthenticationSecurityStateStore.cs | 58 +++++ .../ServiceCollectionExtensions.cs | 14 + .../Dtos/CredentialDto.cs | 4 +- .../Extensions/CredentialTypeParser.cs | 4 +- .../Request/AddCredentialRequest.cs | 4 +- .../Request/SetInitialCredentialRequest.cs | 4 +- .../Request/ValidateCredentialsRequest.cs | 4 +- .../Responses/AddCredentialResult.cs | 4 +- .../Responses/ChangeCredentialResult.cs | 4 +- .../Responses/CredentialProvisionResult.cs | 4 +- .../Abstractions/ICredentialDescriptor.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - .../InMemoryUserSecurityState.cs | 12 - .../InMemoryUserSecurityStateProvider.cs | 19 -- .../InMemoryUserSecurityStateWriter.cs | 53 ---- .../Stores/InMemoryUserSecurityStore.cs | 22 -- .../Abstractions/IUserSecurityEvents.cs | 12 +- .../Abstractions/IUserSecurityState.cs | 12 - .../IUserSecurityStateDebugView.cs | 10 - .../IUserSecurityStateProvider.cs | 9 - .../Abstractions/IUserSecurityStateWriter.cs | 11 - .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 1 + .../Helpers/AuthFlowTestFactory.cs | 6 +- .../Helpers/TestAuthRuntime.cs | 7 +- .../Server/LoginOrchestratorTests.cs | 74 ++---- 68 files changed, 676 insertions(+), 353 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs rename src/CodeBeam.UltimateAuth.Core/Domain/Principals/{CredentialKind.cs => GrantKind.cs} (78%) rename src/CodeBeam.UltimateAuth.Core/Domain/Principals/{PrimaryCredentialKind.cs => PrimaryGrantKind.cs} (70%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs rename src/{credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos => CodeBeam.UltimateAuth.Core/Domain/Security}/CredentialType.cs (85%) rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationExtension.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationHandler.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Authentication/{ => AspNetCore}/UAuthAuthenticationSchemeOptions.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs create mode 100644 src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 350cadd2..22c26b0d 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -12,6 +12,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index 13ddab2b..32b6af51 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -14,6 +14,7 @@ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 22f00b6f..f3f88149 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,3 +1,4 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; @@ -8,8 +9,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -57,6 +56,7 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index 34314a51..7cc56f81 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -15,6 +15,7 @@ + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index e12f87f9..ff22916c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,3 +1,4 @@ +using CodeBeam.UltimateAuth.Authentication.InMemory; using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client; @@ -57,6 +58,7 @@ .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() + .AddUltimateAuthInMemoryAuthenticationSecurity() .AddUltimateAuthArgon2(); builder.Services.AddUltimateAuthClient(o => diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs new file mode 100644 index 00000000..9d5f665a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityManager +{ + Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default); + Task RegisterFailureAsync(AuthenticationSecurityState state, DateTimeOffset now, CancellationToken ct = default); + Task RegisterSuccessAsync(AuthenticationSecurityState state, CancellationToken ct = default); + Task UnlockAsync(AuthenticationSecurityState state, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..8ba53834 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityStateStore +{ + Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); + + Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default); + + Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index 58aac3b8..c8cebe98 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -8,6 +8,7 @@ public sealed record LoginRequest public TenantKey Tenant { get; init; } public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; + public CredentialType Factor { get; init; } = CredentialType.Password; public DateTimeOffset? At { get; init; } public IReadOnlyDictionary? Metadata { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs similarity index 78% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs index 38e076be..601b18f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/CredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum CredentialKind +public enum GrantKind { Session, AccessToken, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs similarity index 70% rename from src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs index e5ddc547..9e12f09d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryCredentialKind.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -public enum PrimaryCredentialKind +public enum PrimaryGrantKind { Stateful, Stateless diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs new file mode 100644 index 00000000..cd0c6c31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthenticationSecurityScope +{ + Account = 0, + Factor = 1 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs new file mode 100644 index 00000000..4aaed649 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -0,0 +1,239 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Security; + +public sealed class AuthenticationSecurityState +{ + public Guid Id { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public AuthenticationSecurityScope Scope { get; } + public CredentialType? CredentialType { get; } + + public int FailedAttempts { get; } + public DateTimeOffset? LastFailedAt { get; } + public DateTimeOffset? LockedUntil { get; } + public bool RequiresReauthentication { get; } + + public long SecurityVersion { get; } + + public bool IsLocked(DateTimeOffset now) => LockedUntil.HasValue && LockedUntil > now; + + private AuthenticationSecurityState( + Guid id, + TenantKey tenant, + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + int failedAttempts, + DateTimeOffset? lastFailedAt, + DateTimeOffset? lockedUntil, + bool requiresReauthentication, + long securityVersion) + { + if (id == Guid.Empty) + throw new UAuthValidationException("security_state_id_required"); + + if (scope == AuthenticationSecurityScope.Account && credentialType is not null) + throw new UAuthValidationException("account_scope_must_not_have_credential_type"); + + if (scope == AuthenticationSecurityScope.Factor && credentialType is null) + throw new UAuthValidationException("factor_scope_requires_credential_type"); + + Id = id; + Tenant = tenant; + UserKey = userKey; + Scope = scope; + CredentialType = credentialType; + FailedAttempts = failedAttempts < 0 ? 0 : failedAttempts; + LastFailedAt = lastFailedAt; + LockedUntil = lockedUntil; + RequiresReauthentication = requiresReauthentication; + SecurityVersion = securityVersion < 0 ? 0 : securityVersion; + } + + public static AuthenticationSecurityState CreateAccount(TenantKey tenant, UserKey userKey, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Account, + credentialType: null, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + securityVersion: 0); + + public static AuthenticationSecurityState CreateFactor(TenantKey tenant, UserKey userKey, CredentialType type, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Factor, + credentialType: type, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + securityVersion: 0); + + /// + /// Resets failures if the last failure is outside the given window. + /// Keeps lock and reauth flags untouched by default. + /// + public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset now, TimeSpan window) + { + if (window <= TimeSpan.Zero) + return this; + + if (LastFailedAt is not DateTimeOffset last) + return this; + + if (now - last <= window) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: LockedUntil, + requiresReauthentication: RequiresReauthentication, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached. + /// If already locked, may extend lock depending on extendLock. + /// + public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true) + { + if (threshold < 0) + throw new UAuthValidationException(nameof(threshold)); + + var nextCount = FailedAttempts + 1; + + DateTimeOffset? nextLockedUntil = LockedUntil; + + if (threshold > 0 && nextCount >= threshold) + { + var candidate = now.Add(lockoutDuration); + + if (nextLockedUntil is null) + nextLockedUntil = candidate; + else if (extendLock && candidate > nextLockedUntil) + nextLockedUntil = candidate; + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: nextCount, + lastFailedAt: now, + lockedUntil: nextLockedUntil, + requiresReauthentication: RequiresReauthentication, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a successful authentication: clears failures and lock. + /// + public AuthenticationSecurityState RegisterSuccess() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: RequiresReauthentication, + securityVersion: SecurityVersion + 1); + + /// + /// Admin/system unlock: clears lock and failures. + /// + public AuthenticationSecurityState Unlock() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: RequiresReauthentication, + securityVersion: SecurityVersion + 1); + + public AuthenticationSecurityState LockUntil(DateTimeOffset until, bool overwriteIfShorter = false) + { + DateTimeOffset? next = LockedUntil; + + if (next is null) + next = until; + else if (overwriteIfShorter || until > next) + next = until; + + if (next == LockedUntil) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + lockedUntil: next, + RequiresReauthentication, + SecurityVersion + 1); + } + + public AuthenticationSecurityState RequireReauthentication() + { + if (RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: true, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReauthentication() + { + if (!RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: false, + securityVersion: SecurityVersion + 1); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs similarity index 85% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs index 9b87807a..35226f1b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Core.Domain; public enum CredentialType { diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 18bebe32..f0c18bf8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -29,13 +29,24 @@ public sealed class UAuthLoginOptions /// /// This property defines the window of time used to evaluate consecutive failed login attempts. /// If the number of failures within this window exceeds the configured threshold, the account may be locked out. - /// Adjusting this value affects how quickly lockout conditions are triggered. + /// Adjusting this value affects how quickly lockout conditions are triggered. + /// public TimeSpan FailureWindow { get; set; } = TimeSpan.FromMinutes(15); + /// + /// Gets or sets a value indicating whether the lock should be extended when a login attempt fails during lockout. + /// + /// Set this property to to automatically extend the lock duration after + /// each failed login attempt. This can help prevent repeated unauthorized access attempts by increasing the lockout period. + /// + public bool ExtendLockOnFailure { get; set; } = false; + internal UAuthLoginOptions Clone() => new() { MaxFailedAttempts = MaxFailedAttempts, LockoutDuration = LockoutDuration, - IncludeFailureDetails = IncludeFailureDetails + IncludeFailureDetails = IncludeFailureDetails, + FailureWindow = FailureWindow, + ExtendLockOnFailure = ExtendLockOnFailure }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index 11e5e962..b364a558 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface ICredentialResponseWriter { - void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId); - void Write(HttpContext context, CredentialKind kind, AccessToken accessToken); - void Write(HttpContext context, CredentialKind kind, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); + void Write(HttpContext context, GrantKind kind, AccessToken accessToken); + void Write(HttpContext context, GrantKind kind, RefreshToken refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs index 52d54c7e..d4bfbfca 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -5,5 +5,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstractions; public interface IPrimaryCredentialResolver { - PrimaryCredentialKind Resolve(HttpContext context); + PrimaryGrantKind Resolve(HttpContext context); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs index cc3da3d8..acf78ac1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -26,21 +26,21 @@ private static UAuthResponseOptions PureOpaque(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie, }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, @@ -62,21 +62,21 @@ private static UAuthResponseOptions Hybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Cookie }, @@ -98,21 +98,21 @@ private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, @@ -134,21 +134,21 @@ private static UAuthResponseOptions PureJwt(AuthFlowType flow) SessionIdDelivery = new() { Name = "uas", - Kind = CredentialKind.Session, + Kind = GrantKind.Session, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.None }, AccessTokenDelivery = new() { Name = "uat", - Kind = CredentialKind.AccessToken, + Kind = GrantKind.AccessToken, TokenFormat = TokenFormat.Jwt, Mode = TokenResponseMode.Header }, RefreshTokenDelivery = new() { Name = "uar", - Kind = CredentialKind.RefreshToken, + Kind = GrantKind.RefreshToken, TokenFormat = TokenFormat.Opaque, Mode = TokenResponseMode.Header }, diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 8ae5fcbd..7d5bedd6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -54,9 +54,9 @@ private static CredentialResponseOptions Bind(CredentialResponseOptions delivery var cookie = delivery.Kind switch { - CredentialKind.Session => server.Cookie.Session, - CredentialKind.AccessToken => server.Cookie.AccessToken, - CredentialKind.RefreshToken => server.Cookie.RefreshToken, + GrantKind.Session => server.Cookie.Session, + GrantKind.AccessToken => server.Cookie.AccessToken, + GrantKind.RefreshToken => server.Cookie.RefreshToken, _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") }; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index a9726933..cb64e072 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -14,9 +14,9 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf return new UAuthResponseOptions { - SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, CredentialKind.Session, clientProfile), - AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, CredentialKind.AccessToken, clientProfile), - RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, CredentialKind.RefreshToken, clientProfile), + SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, GrantKind.Session, clientProfile), + AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, GrantKind.AccessToken, clientProfile), + RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, GrantKind.RefreshToken, clientProfile), Login = MergeLogin(template.Login, configured.Login), Logout = MergeLogout(template.Logout, configured.Logout) @@ -27,7 +27,7 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf // effectiveMode and effectiveOptions are intentionally passed // to keep this adapter policy-extensible. // They will be used for future mode/option based response enforcement. - private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, CredentialKind kind, UAuthClientProfile clientProfile) + private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, GrantKind kind, UAuthClientProfile clientProfile) { if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) { diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationExtension.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationSchemeOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs new file mode 100644 index 00000000..bcda5f9e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager +{ + private readonly IAuthenticationSecurityStateStore _store; + private readonly UAuthServerOptions _options; + + public AuthenticationSecurityManager(IAuthenticationSecurityStateStore store, IOptions options) + { + _store = store; + _options = options.Value; + } + + public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateAccount(tenant, userKey); + await _store.AddAsync(created, ct); + return created; + } + + public async Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default) + { + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Factor, type, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateFactor(tenant, userKey, type); + await _store.AddAsync(created, ct); + return created; + } + + public async Task RegisterFailureAsync(AuthenticationSecurityState state, DateTimeOffset now, CancellationToken ct = default) + { + var loginOptions = _options.Login; + + var threshold = loginOptions.MaxFailedAttempts; + var duration = loginOptions.LockoutDuration; + var window = loginOptions.FailureWindow; + var extendLock = loginOptions.ExtendLockOnFailure; + + if (window > TimeSpan.Zero) + { + state = state.ResetFailuresIfWindowExpired(now, window); + } + + var updated = state.RegisterFailure(now, threshold, duration, extendLock: extendLock); + await PersistWithRetryAsync(updated, state.SecurityVersion, ct); + return updated; + } + + public async Task RegisterSuccessAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + var updated = state.RegisterSuccess(); + await PersistWithRetryAsync(updated, state.SecurityVersion, ct); + return updated; + } + + public async Task UnlockAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + var updated = state.Unlock(); + await PersistWithRetryAsync(updated, state.SecurityVersion, ct); + return updated; + } + + private async Task PersistWithRetryAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct) + { + const int maxRetries = 3; + + for (var i = 0; i < maxRetries; i++) + { + try + { + await _store.UpdateAsync(updated, expectedVersion, ct); + return; + } + catch (InvalidOperationException) + { + if (i == maxRetries - 1) + throw; + + var current = await _store.GetAsync(updated.Tenant, updated.UserKey, updated.Scope, updated.CredentialType, ct); + + if (current is null) + throw; + + updated = current; + expectedVersion = current.SecurityVersion; + } + } + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs index 1d7a15d0..98c509f8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Contracts; public sealed record ResolvedCredential { - public PrimaryCredentialKind Kind { get; init; } + public PrimaryGrantKind Kind { get; init; } /// /// Raw credential value (session id / jwt / opaque) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 07c33dc3..e3b0f3e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -74,17 +74,17 @@ public async Task LoginAsync(HttpContext ctx) if (result.SessionId is AuthSessionId sessionId) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, sessionId); + _credentialResponseWriter.Write(ctx, GrantKind.Session, sessionId); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 280806d8..b5bbd9e3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -144,17 +144,17 @@ public async Task CompleteAsync(HttpContext ctx) if (result.SessionId is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialResponseWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } if (result.AccessToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (result.RefreshToken is not null) { - _credentialResponseWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } var decision = _redirectResolver.ResolveSuccess(authContext, ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index d4c3df38..1d3c3f3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -59,18 +59,18 @@ public async Task RefreshAsync(HttpContext ctx) var primary = _refreshPolicy.SelectPrimary(flow, request, result); - if (primary == CredentialKind.Session && result.SessionId is not null) + if (primary == GrantKind.Session && result.SessionId is not null) { - _credentialWriter.Write(ctx, CredentialKind.Session, result.SessionId.Value); + _credentialWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); } - else if (primary == CredentialKind.AccessToken && result.AccessToken is not null) + else if (primary == GrantKind.AccessToken && result.AccessToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.AccessToken, result.AccessToken); + _credentialWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); } if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) { - _credentialWriter.Write(ctx, CredentialKind.RefreshToken, result.RefreshToken); + _credentialWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index b8f09be6..808c8717 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -46,7 +46,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } - if (credential.Kind == PrimaryCredentialKind.Stateful) + if (credential.Kind == PrimaryGrantKind.Stateful) { if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 1fbf8a9d..bc54e429 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Runtime; +using CodeBeam.UltimateAuth.Server.Security; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Users.Contracts; @@ -203,6 +204,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index a41344b2..29a97238 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -18,7 +18,7 @@ public LoginDecision Decide(LoginDecisionContext context) var state = context.SecurityState; if (state is not null) { - if (state.IsLocked) + if (state.IsLocked(DateTimeOffset.UtcNow)) return LoginDecision.Deny(AuthFailureReason.LockedOut); if (state.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs index 72a53af6..c88a9aa4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Security; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -33,7 +33,8 @@ public sealed class LoginDecisionContext /// /// Gets the user security state if the user could be resolved. /// - public IUserSecurityState? SecurityState { get; init; } + //public IUserSecurityState? SecurityState { get; init; } + public AuthenticationSecurityState? SecurityState { get; init; } /// /// Indicates whether the user exists. diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index a7c4ab45..8c2a1889 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; @@ -26,8 +27,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; - private readonly IUserSecurityStateWriter _securityWriter; - private readonly IUserSecurityStateProvider _securityStateProvider; // runtime risk + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; @@ -40,8 +40,7 @@ public LoginOrchestrator( ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, - IUserSecurityStateWriter securityWriter, - IUserSecurityStateProvider securityStateProvider, + IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, IOptions options) { @@ -53,8 +52,7 @@ public LoginOrchestrator( _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; - _securityWriter = securityWriter; - _securityStateProvider = securityStateProvider; + _authenticationSecurityManager = authenticationSecurityManager; _events = events; _options = options.Value; } @@ -64,18 +62,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req ct.ThrowIfCancellationRequested(); var now = request.At ?? DateTimeOffset.UtcNow; - var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); - var userKey = resolution?.UserKey; bool userExists = false; bool credentialsValid = false; - IUserSecurityState? securityState = null; - - DateTimeOffset? lockoutUntilUtc = null; - int? remainingAttempts = null; + AuthenticationSecurityState? accountState = null; + AuthenticationSecurityState? factorState = null; if (userKey is not null) { @@ -84,27 +78,23 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { userExists = true; - securityState = await _securityStateProvider.GetAsync(request.Tenant, userKey.Value, ct); + accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(request.Tenant, userKey.Value, ct); - if (securityState?.LastFailedAt is DateTimeOffset lastFail && _options.Login.FailureWindow is { } window && now - lastFail > window) + if (accountState.IsLocked(now)) { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; + return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } - if (securityState?.LockedUntil is DateTimeOffset until && until <= now) - { - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); - securityState = null; - } + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(request.Tenant, userKey.Value, request.Factor, ct); - if (securityState?.LockedUntil is DateTimeOffset stillLocked && stillLocked > now) + if (factorState.IsLocked(now)) { - return LoginResult.Failed(AuthFailureReason.LockedOut, stillLocked, 0); + return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); } var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct); + // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user foreach (var credential in credentials.OfType()) { if (!credential.Security.IsUsable(now)) @@ -121,6 +111,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } } + // TODO: Add accountState here, currently it only checks factor state var decisionContext = new LoginDecisionContext { Tenant = request.Tenant, @@ -128,7 +119,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req CredentialsValid = credentialsValid, UserExists = userExists, UserKey = userKey, - SecurityState = securityState, + SecurityState = factorState, IsChained = request.ChainId is not null }; @@ -138,46 +129,38 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (decision.Kind == LoginDecisionKind.Deny) { - if (userKey is not null && userExists) + if (userKey is not null && userExists && factorState is not null) { - var isCurrentlyLocked = - securityState?.IsLocked == true && - securityState?.LockedUntil is DateTimeOffset until && - until > now; - - if (!isCurrentlyLocked) - { - await _securityWriter.RecordFailedLoginAsync(request.Tenant, userKey.Value, now, ct); + factorState = await _authenticationSecurityManager.RegisterFailureAsync(factorState, now, ct); - var currentFailures = securityState?.FailedLoginAttempts ?? 0; - var nextCount = currentFailures + 1; + DateTimeOffset? lockedUntil = null; + int? remainingAttempts = null; - if (max > 0) + if (_options.Login.IncludeFailureDetails) + { + if (factorState.IsLocked(now)) { - if (nextCount >= max) - { - lockoutUntilUtc = now.Add(_options.Login.LockoutDuration); - await _securityWriter.LockUntilAsync(request.Tenant, userKey.Value, lockoutUntilUtc.Value, ct); - remainingAttempts = 0; - - return LoginResult.Failed(AuthFailureReason.LockedOut, lockoutUntilUtc, remainingAttempts); - } - else - { - remainingAttempts = max - nextCount; - } + lockedUntil = factorState.LockedUntil; + remainingAttempts = 0; + } + else if (_options.Login.MaxFailedAttempts > 0) + { + remainingAttempts = _options.Login.MaxFailedAttempts - factorState.FailedAttempts; } } - else - { - lockoutUntilUtc = securityState!.LockedUntil; - remainingAttempts = 0; - } + + return LoginResult.Failed( + factorState.IsLocked(now) + ? AuthFailureReason.LockedOut + : decision.FailureReason, + lockedUntil, + remainingAttempts); } - return LoginResult.Failed(decision.FailureReason, lockoutUntilUtc, remainingAttempts); + return LoginResult.Failed(decision.FailureReason); } + if (decision.Kind == LoginDecisionKind.Challenge) { return LoginResult.Continue(new LoginContinuation @@ -190,7 +173,10 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.InvalidCredentials); // After this point, the login is successful. We can reset any failure counts and proceed to create a session. - await _securityWriter.ResetFailuresAsync(request.Tenant, userKey.Value, ct); + if (factorState is not null) + { + await _authenticationSecurityManager.RegisterSuccessAsync(factorState, ct); + } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs index fbb34c10..9d8e1515 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs @@ -6,6 +6,6 @@ namespace CodeBeam.UltimateAuth.Server.Flows; public interface IRefreshResponsePolicy { - CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); bool WriteRefreshToken(AuthFlowContext flow); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs index 6e909cdb..e29bc768 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs @@ -8,28 +8,28 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal class RefreshResponsePolicy : IRefreshResponsePolicy { - public CredentialKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + public GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) { if (flow.EffectiveMode == UAuthMode.PureOpaque) - return CredentialKind.Session; + return GrantKind.Session; if (flow.EffectiveMode == UAuthMode.PureJwt) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) { - return CredentialKind.AccessToken; + return GrantKind.AccessToken; } if (request.SessionId != null) { - return CredentialKind.Session; + return GrantKind.Session; } if (flow.ClientProfile == UAuthClientProfile.Api) - return CredentialKind.AccessToken; + return GrantKind.AccessToken; - return CredentialKind.Session; + return GrantKind.Session; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs index 18d3f160..786498ed 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -7,5 +7,5 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public interface IUAuthCookiePolicyBuilder { - CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind); + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 9d75487a..78b0af0a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder { - public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, CredentialKind kind) + public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind) { if (response.Cookie is null) throw new InvalidOperationException("Cookie policy requested but Cookie options are null."); @@ -43,7 +43,7 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC }; } - private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, CredentialKind kind) + private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, GrantKind kind) { var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero; var baseLifetime = ResolveBaseLifetime(context, kind, src); @@ -54,7 +54,7 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, } } - private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, CredentialKind kind, UAuthCookieOptions src) + private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, GrantKind kind, UAuthCookieOptions src) { if (src.MaxAge is not null) return src.MaxAge; @@ -64,9 +64,9 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { - CredentialKind.Session => ResolveSessionLifetime(context), - CredentialKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, - CredentialKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, + GrantKind.Session => ResolveSessionLifetime(context), + GrantKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + GrantKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, _ => null }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index add44279..6c43139c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -27,16 +27,16 @@ public CredentialResponseWriter( _headerPolicy = headerPolicy; } - public void Write(HttpContext context, CredentialKind kind, AuthSessionId sessionId) + public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) => WriteInternal(context, kind, sessionId.ToString()); - public void Write(HttpContext context, CredentialKind kind, AccessToken token) + public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, CredentialKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshToken token) => WriteInternal(context, kind, token.Token); - public void WriteInternal(HttpContext context, CredentialKind kind, string value) + public void WriteInternal(HttpContext context, GrantKind kind, string value) { var auth = _authContext.Current; var delivery = ResolveDelivery(auth.Response, kind); @@ -61,7 +61,7 @@ public void WriteInternal(HttpContext context, CredentialKind kind, string value } } - private void WriteCookie(HttpContext context, CredentialKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) + private void WriteCookie(HttpContext context, GrantKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) { if (options.Cookie is null) throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); @@ -78,12 +78,12 @@ private void WriteHeader(HttpContext context, string value, CredentialResponseOp context.Response.Headers[headerName] = formatted; } - private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, CredentialKind kind) + private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, GrantKind kind) => kind switch { - CredentialKind.Session => response.SessionIdDelivery, - CredentialKind.AccessToken => response.AccessTokenDelivery, - CredentialKind.RefreshToken => response.RefreshTokenDelivery, + GrantKind.Session => response.SessionIdDelivery, + GrantKind.AccessToken => response.AccessTokenDelivery, + GrantKind.RefreshToken => response.RefreshTokenDelivery, _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs index ffe1092d..8aa3f572 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/FlowCredentialResolver.cs @@ -23,8 +23,8 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return kind switch { - PrimaryCredentialKind.Stateful => ResolveSession(context, response), - PrimaryCredentialKind.Stateless => ResolveAccessToken(context, response), + PrimaryGrantKind.Stateful => ResolveSession(context, response), + PrimaryGrantKind.Stateless => ResolveAccessToken(context, response), _ => null }; @@ -49,7 +49,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateful, + Kind = PrimaryGrantKind.Stateful, Value = raw.Trim(), Tenant = context.GetTenant(), Device = context.GetDevice() @@ -81,7 +81,7 @@ public FlowCredentialResolver(IPrimaryCredentialResolver primaryResolver) return new ResolvedCredential { - Kind = PrimaryCredentialKind.Stateless, + Kind = PrimaryGrantKind.Stateless, Value = value, Tenant = context.GetTenant(), Device = context.GetDevice() diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs index 595f7aae..3ba31678 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs @@ -16,7 +16,7 @@ public PrimaryCredentialResolver(IOptions options) _options = options.Value; } - public PrimaryCredentialKind Resolve(HttpContext context) + public PrimaryGrantKind Resolve(HttpContext context) { if (IsApiRequest(context)) return _options.PrimaryCredential.Api; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index a62c5c7b..e1245421 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Options; public sealed class CredentialResponseOptions { - public CredentialKind Kind { get; init; } + public GrantKind Kind { get; init; } public TokenResponseMode Mode { get; set; } = TokenResponseMode.None; /// @@ -48,7 +48,7 @@ public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) }; } - public static CredentialResponseOptions Disabled(CredentialKind kind) + public static CredentialResponseOptions Disabled(GrantKind kind) => new() { Kind = kind, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs index 1da6d92d..685bd2a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -7,12 +7,12 @@ public sealed class UAuthPrimaryCredentialPolicy /// /// Default primary credential for UI-style requests. /// - public PrimaryCredentialKind Ui { get; set; } = PrimaryCredentialKind.Stateful; + public PrimaryGrantKind Ui { get; set; } = PrimaryGrantKind.Stateful; /// /// Default primary credential for API requests. /// - public PrimaryCredentialKind Api { get; set; } = PrimaryCredentialKind.Stateless; + public PrimaryGrantKind Api { get; set; } = PrimaryGrantKind.Stateless; internal UAuthPrimaryCredentialPolicy Clone() => new() { diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj new file mode 100644 index 00000000..0ad38403 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj @@ -0,0 +1,16 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..8d9dcfec --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -0,0 +1,58 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +internal sealed class InMemoryAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore +{ + private readonly ConcurrentDictionary _byId = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); + + public Task GetAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_index.TryGetValue((tenant, userKey, scope, credentialType), out var id) && _byId.TryGetValue(id, out var state)) + { + return Task.FromResult(state); + } + + return Task.FromResult(null); + } + + public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryAdd(key, state.Id)) + throw new InvalidOperationException("security_state_already_exists"); + + if (!_byId.TryAdd(state.Id, state)) + { + _index.TryRemove(key, out _); + throw new InvalidOperationException("security_state_add_failed"); + } + + return Task.CompletedTask; + } + + public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byId.TryGetValue(state.Id, out var current)) + throw new InvalidOperationException("security_state_not_found"); + + if (current.SecurityVersion != expectedVersion) + throw new InvalidOperationException("security_state_version_conflict"); + + _byId[state.Id] = state; + + return Task.CompletedTask; + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ab406e99 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthInMemoryAuthenticationSecurity(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index d9ccf974..e0e60046 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialDto { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs index d90804d7..d75b3543 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public static class CredentialTypeParser { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index 89a903c5..2f6b46dd 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialRequest() { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs index 2ccd937b..a40a8345 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record SetInitialCredentialRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs index bd530568..0268a697 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ValidateCredentialsRequest { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs index c8a2b5a1..5a226706 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialResult { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs index 8579bc23..bddc8695 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ChangeCredentialResult { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs index 36d9ae8d..c116b8e6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialProvisionResult { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index 63deb9ca..25dc159d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 01db0848..9492398b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -14,9 +14,6 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton, InMemoryUserIdProvider>(); // Seed never try add diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs deleted file mode 100644 index 71ec819a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityState : IUserSecurityState -{ - public long SecurityVersion { get; init; } - public int FailedLoginAttempts { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public bool RequiresReauthentication { get; init; } - public DateTimeOffset? LastFailedAt { get; init; } - - public bool IsLocked => LockedUntil.HasValue && LockedUntil.Value > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs deleted file mode 100644 index b0331abd..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateProvider(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.Get(tenant, userKey)); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs deleted file mode 100644 index a97f1f44..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateWriter.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStateWriter : IUserSecurityStateWriter -{ - private readonly InMemoryUserSecurityStore _store; - - public InMemoryUserSecurityStateWriter(InMemoryUserSecurityStore store) - { - _store = store; - } - - public Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = (current?.FailedLoginAttempts ?? 0) + 1, - LockedUntil = current?.LockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false, - LastFailedAt = at - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default) - { - var current = _store.Get(tenant, userKey); - - var next = new InMemoryUserSecurityState - { - SecurityVersion = (current?.SecurityVersion ?? 0) + 1, - FailedLoginAttempts = current?.FailedLoginAttempts ?? 0, - LockedUntil = lockedUntil, - RequiresReauthentication = current?.RequiresReauthentication ?? false - }; - - _store.Set(tenant, userKey, next); - return Task.CompletedTask; - } - - public Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - _store.Clear(tenant, userKey); - return Task.CompletedTask; - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs deleted file mode 100644 index cbbd3fc0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSecurityStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.Collections.Concurrent; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserSecurityStore : IUserSecurityStateDebugView -{ - private readonly ConcurrentDictionary<(TenantKey, UserKey), InMemoryUserSecurityState> _states = new(); - - public InMemoryUserSecurityState? Get(TenantKey tenant, UserKey userKey) - => _states.TryGetValue((tenant, userKey), out var state) ? state : null; - - public void Set(TenantKey tenant, UserKey userKey, InMemoryUserSecurityState state) - => _states[(tenant, userKey)] = state; - - public void Clear(TenantKey tenant, UserKey userKey) - => _states.TryRemove((tenant, userKey), out _); - - public IUserSecurityState? GetState(TenantKey tenant, UserKey userKey) - => Get(tenant, userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs index 4c1e0cbd..01dea3d1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -1,8 +1,10 @@ -namespace CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Core.Domain; -public interface IUserSecurityEvents +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents { - Task OnUserActivatedAsync(TUserId userId); - Task OnUserDeactivatedAsync(TUserId userId); - Task OnSecurityInvalidatedAsync(TUserId userId); + Task OnUserActivatedAsync(UserKey userKey); + Task OnUserDeactivatedAsync(UserKey userKey); + Task OnSecurityInvalidatedAsync(UserKey userKey); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs deleted file mode 100644 index 74fa08d0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityState -{ - long SecurityVersion { get; } - int FailedLoginAttempts { get; } - DateTimeOffset? LockedUntil { get; } - bool RequiresReauthentication { get; } - DateTimeOffset? LastFailedAt { get; } - - bool IsLocked => LockedUntil.HasValue && LockedUntil > DateTimeOffset.UtcNow; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs deleted file mode 100644 index 6c97bc7e..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateDebugView.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -internal interface IUserSecurityStateDebugView -{ - IUserSecurityState? GetState(TenantKey tenant, UserKey userKey); - void Clear(TenantKey tenant, UserKey userKey); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs deleted file mode 100644 index b22955f0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateProvider -{ - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs deleted file mode 100644 index 20cb713b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateWriter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users; - -public interface IUserSecurityStateWriter -{ - Task RecordFailedLoginAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default); - Task ResetFailuresAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task LockUntilAsync(TenantKey tenant, UserKey userKey, DateTimeOffset lockedUntil, CancellationToken ct = default); -} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 6e11feeb..f0adf68b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index 67a4b94d..46de8a34 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -25,9 +25,9 @@ public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, originalOptions: TestServerOptions.Default(), effectiveOptions: TestServerOptions.Effective(), response: new EffectiveAuthResponse( - sessionIdDelivery: CredentialResponseOptions.Disabled(CredentialKind.Session), - accessTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.AccessToken), - refreshTokenDelivery: CredentialResponseOptions.Disabled(CredentialKind.RefreshToken), + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), redirect: redirect ?? EffectiveRedirectResponse.Disabled ), primaryTokenKind: PrimaryTokenKind.Session, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index d5582a60..93150800 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; @@ -43,12 +44,16 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddUltimateAuthCredentialsInMemory(); services.AddUltimateAuthInMemorySessions(); services.AddUltimateAuthInMemoryTokens(); + services.AddUltimateAuthInMemoryAuthenticationSecurity(); services.AddUltimateAuthAuthorizationInMemory(); services.AddUltimateAuthAuthorizationReference(); services.AddUltimateAuthUsersReference(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 10f97f37..2f6aabb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -1,13 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; -using CodeBeam.UltimateAuth.Users.InMemory; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using System.Security; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -26,7 +25,6 @@ public async Task Successful_login_should_return_success_result() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeTrue(); @@ -45,7 +43,6 @@ public async Task Successful_login_should_create_session() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default(), }); result.SessionId.Should().NotBeNull(); @@ -68,14 +65,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - - state!.FailedLoginAttempts.Should().Be(1); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(1); } [Fact] @@ -95,7 +89,6 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); await orchestrator.LoginAsync(flow, @@ -104,13 +97,11 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "user", // valid password - //Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - - var state = store.GetState(TenantKey.Single, TestUsers.User); - state.Should().BeNull(); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(0); } [Fact] @@ -126,7 +117,6 @@ public async Task Invalid_password_should_fail_login() Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -145,7 +135,6 @@ public async Task Non_existent_user_should_fail_login_gracefully() Tenant = TenantKey.Single, Identifier = "ghost", Secret = "whatever", - //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -168,13 +157,12 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - state!.IsLocked.Should().BeTrue(); + state!.IsLocked(DateTimeOffset.UtcNow).Should().BeTrue(); } [Fact] @@ -188,24 +176,20 @@ public async Task Locked_user_should_not_login_even_with_correct_password() var orchestrator = runtime.GetLoginOrchestrator(); var flow = await runtime.CreateLoginFlowAsync(); - // lock await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - // try again with correct password var result = await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default(), }); result.IsSuccess.Should().BeFalse(); @@ -228,24 +212,20 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); await orchestrator.LoginAsync(flow, new LoginRequest { Tenant = TenantKey.Single, Identifier = "user", - Secret = "wrong", - //Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - - state2!.FailedLoginAttempts.Should().Be(state1!.FailedLoginAttempts); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); } [Fact] @@ -267,15 +247,14 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); } - var store = runtime.Services.GetRequiredService(); - var state = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); - state!.IsLocked.Should().BeFalse(); - state.FailedLoginAttempts.Should().Be(5); + state?.IsLocked(DateTimeOffset.UtcNow).Should().BeFalse(); + state?.FailedAttempts.Should().Be(5); } [Fact] @@ -296,11 +275,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - var store = runtime.Services.GetRequiredService(); - var state1 = store.GetState(TenantKey.Single, TestUsers.User); + var store = runtime.Services.GetRequiredService(); + var state1 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); var lockedUntil = state1!.LockedUntil; @@ -310,11 +288,10 @@ await orchestrator.LoginAsync(flow, Tenant = TenantKey.Single, Identifier = "user", Secret = "wrong", - //Device = TestDevice.Default(), }); - var state2 = store.GetState(TenantKey.Single, TestUsers.User); - state2!.LockedUntil.Should().Be(lockedUntil); + var state2 = await store.GetAsync(TenantKey.Single, TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.LockedUntil.Should().Be(lockedUntil); } [Fact] @@ -339,7 +316,6 @@ public async Task Login_success_should_trigger_UserLoggedIn_event() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default() }); captured.Should().NotBeNull(); @@ -368,7 +344,6 @@ public async Task Login_success_should_trigger_OnAnyEvent() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default() }); count.Should().BeGreaterThan(0); @@ -390,7 +365,6 @@ public async Task Event_handler_exception_should_not_break_login_flow() Tenant = TenantKey.Single, Identifier = "user", Secret = "user", - //Device = TestDevice.Default() }); result.IsSuccess.Should().BeTrue(); From 1aafa3b6adef8109ca76c3b12e341bdc22fa7694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 3 Mar 2026 22:21:34 +0300 Subject: [PATCH 13/29] Complete Credential Change & Tests --- .../Components/Dialogs/CredentialDialog.razor | 95 +++++++++++++ .../Components/Pages/Home.razor | 4 +- .../Components/Pages/Home.razor.cs | 5 + .../Services/ICredentialClient.cs | 18 +-- .../Services/UAuthCredentialClient.cs | 36 ++--- .../Dtos/CredentialDto.cs | 2 + .../Responses/ChangeCredentialResult.cs | 6 +- .../InMemoryCredentialSeedContributor.cs | 5 +- .../InMemoryCredentialStore.cs | 5 +- .../Domain/PasswordCredential.cs | 130 ++++++++--------- .../PasswordCredentialFactory.cs | 19 --- .../PasswordUserLifecycleIntegration.cs | 5 +- .../Services/CredentialManagementService.cs | 44 +++--- .../Abstractions/ICredentialDescriptor.cs | 1 + .../Argon2PasswordHasher.cs | 3 +- .../Credentials/ChangePasswordTests.cs | 134 ++++++++++++++++++ .../Helpers/TestAuthRuntime.cs | 10 +- 17 files changed, 378 insertions(+), 144 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor new file mode 100644 index 00000000..d9f9a0d9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,95 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Credential Management + User: @AuthState?.Identity?.DisplayName + + + + + + + + Change Password + + + + + Cancel + OK + + + +@code { + private MudForm _form = null!; + private string? _oldPassword; + private string? _newPassword; + private string? _newPasswordCheck; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + + } + } + + private async Task ChangePasswordAsync() + { + if (_form is null) + return; + + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Form is not valid.", Severity.Error); + return; + } + + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("New password and check do not match", Severity.Error); + return; + } + + ChangeCredentialRequest request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword!, + }; + + var result = await UAuthClient.Credentials.ChangeMyAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Password changed successfully", Severity.Success); + await UAuthClient.Flows.LogoutAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "An error occurred while changing password", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : null; + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index fab90a2c..8253b81b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -226,8 +226,8 @@ Manage Identifiers - - Change Password + + Manage Credentials diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index de4b2066..704a53fc 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -161,6 +161,11 @@ private async Task OpenSessionDialog() await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), GetDialogOptions()); } + private async Task OpenCredentialDialog() + { + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), GetDialogOptions()); + } + private DialogOptions GetDialogOptions() { return new DialogOptions diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs index eb92db92..af65bc96 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs @@ -8,16 +8,16 @@ public interface ICredentialClient { Task> GetMyAsync(); Task> AddMyAsync(AddCredentialRequest request); - Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); - Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); - Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); + Task> ChangeMyAsync(ChangeCredentialRequest request); + Task RevokeMyAsync(RevokeCredentialRequest request); + Task BeginResetMyAsync(BeginCredentialResetRequest request); + Task CompleteResetMyAsync(CompleteCredentialResetRequest request); Task> GetUserAsync(UserKey userKey); Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); - Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); - Task ActivateUserAsync(UserKey userKey, CredentialType type); - Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); - Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); - Task DeleteUserAsync(UserKey userKey, CredentialType type); + Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); + Task ActivateUserAsync(UserKey userKey); + Task BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request); + Task CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request); + Task DeleteUserAsync(UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 1ef35801..6fbd90d4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -32,27 +32,27 @@ public async Task> AddMyAsync(AddCredentialRequ return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + public async Task> ChangeMyAsync(ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/change"), request); return UAuthResultMapper.FromJson(raw); } - public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) + public async Task RevokeMyAsync(RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) + public async Task BeginResetMyAsync(BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/reset/begin"), request); return UAuthResultMapper.From(raw); } - public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + public async Task CompleteResetMyAsync(CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); + var raw = await _request.SendJsonAsync(Url($"/credentials/reset/complete"), request); return UAuthResultMapper.From(raw); } @@ -69,33 +69,33 @@ public async Task> AddUserAsync(UserKey userKey return UAuthResultMapper.FromJson(raw); } - public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + public async Task ActivateUserAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/activate")); return UAuthResultMapper.From(raw); } - public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + public async Task BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); return UAuthResultMapper.From(raw); } - public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + public async Task CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); return UAuthResultMapper.From(raw); } - public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + public async Task DeleteUserAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/delete")); return UAuthResultMapper.From(raw); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index e0e60046..5d3e4b07 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -21,4 +21,6 @@ public sealed record CredentialDto public DateTimeOffset? ResetRequestedAt { get; init; } public string? Source { get; init; } + + public long Version { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs index bddc8695..6a78c338 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record ChangeCredentialResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? Error { get; init; } @@ -13,14 +13,14 @@ public sealed record ChangeCredentialResult public static ChangeCredentialResult Success(CredentialType type) => new() { - Succeeded = true, + IsSuccess = true, Type = type }; public static ChangeCredentialResult Fail(string error) => new() { - Succeeded = false, + IsSuccess = false, Error = error }; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index d16f3493..42cfd84f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -38,15 +38,14 @@ private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, strin { await _credentials.AddAsync( tenant, - new PasswordCredential( + PasswordCredential.Create( credentialId, tenant, userKey, _hasher.Hash(secretHash), CredentialSecurityState.Active(), new CredentialMetadata(), - DateTimeOffset.UtcNow, - null), + DateTimeOffset.UtcNow), ct); } catch (UAuthConflictException) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 7598a8c8..e46aaca1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Reference; using System.Collections.Concurrent; +using System.Text; namespace CodeBeam.UltimateAuth.Credentials.InMemory; @@ -57,10 +58,10 @@ public Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedV var key = (tenant, pwd.Id); - if (!_store.ContainsKey(key)) + if (!_store.TryGetValue(key, out var current)) throw new UAuthNotFoundException("credential_not_found"); - if (pwd.Version != expectedVersion) + if (current.Version != expectedVersion) throw new UAuthConflictException("credential_version_conflict"); _store[key] = pwd; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 7ffc1558..356ffe67 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -13,37 +13,45 @@ public sealed class PasswordCredential : ISecretCredential, ICredentialDescripto public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - public string SecretHash { get; private set; } - public CredentialSecurityState Security { get; private set; } - public CredentialMetadata Metadata { get; private set; } + public string SecretHash { get; init; } = default!; + public CredentialSecurityState Security { get; init; } = CredentialSecurityState.Active(); + public CredentialMetadata Metadata { get; init; } = new CredentialMetadata(); public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; init; } public long Version { get; private set; } public bool IsRevoked => Security.RevokedAt is not null; public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; - public PasswordCredential( - Guid? id, + public PasswordCredential() { } + + private PasswordCredential( + Guid id, TenantKey tenant, UserKey userKey, string secretHash, CredentialSecurityState security, CredentialMetadata metadata, DateTimeOffset createdAt, - DateTimeOffset? updatedAt) + DateTimeOffset? updatedAt, + long version) { - Id = id ?? Guid.NewGuid(); + if (id == Guid.Empty) throw new UAuthValidationException("credential_id_required"); + if (string.IsNullOrWhiteSpace(secretHash)) throw new UAuthValidationException("credential_secret_required"); + + Id = id; Tenant = tenant; UserKey = userKey; - SecretHash = secretHash; + SecretHash = !string.IsNullOrWhiteSpace(secretHash) + ? secretHash + : throw new UAuthValidationException("credential_secret_required"); Security = security; - Metadata = metadata; + Metadata = metadata ?? new CredentialMetadata(); CreatedAt = createdAt; UpdatedAt = updatedAt; - Version = 0; + Version = version; } public static PasswordCredential Create( @@ -53,21 +61,35 @@ public static PasswordCredential Create( string secretHash, CredentialSecurityState security, CredentialMetadata metadata, - DateTimeOffset createdAt, - DateTimeOffset? updatedAt) - { - return new( - id ?? Guid.NewGuid(), - tenant, - userKey, - secretHash, - security, - metadata, - createdAt, - updatedAt); - } - - public void ChangeSecret(string newSecretHash, DateTimeOffset now) + DateTimeOffset now) + => new( + id: id ?? Guid.NewGuid(), + tenant: tenant, + userKey: userKey, + secretHash: secretHash, + security: security, + metadata: metadata, + createdAt: now, + updatedAt: null, + version: 0); + + private PasswordCredential Next( + string? secretHash = null, + CredentialSecurityState? security = null, + CredentialMetadata? metadata = null, + DateTimeOffset? updatedAt = null) + => new( + id: Id, + tenant: Tenant, + userKey: UserKey, + secretHash: secretHash ?? SecretHash, + security: security ?? Security, + metadata: metadata ?? Metadata, + createdAt: CreatedAt, + updatedAt: updatedAt ?? UpdatedAt, + version: Version + 1); + + public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newSecretHash)) throw new UAuthValidationException("credential_secret_required"); @@ -78,58 +100,32 @@ public void ChangeSecret(string newSecretHash, DateTimeOffset now) if (IsExpired(now)) throw new UAuthConflictException("credential_expired"); - if (SecretHash == newSecretHash) + if (string.Equals(SecretHash, newSecretHash, StringComparison.Ordinal)) throw new UAuthValidationException("credential_secret_same"); - SecretHash = newSecretHash; - Security = Security.RotateStamp(); - UpdatedAt = now; - Version++; + return Next(newSecretHash, Security.RotateStamp(), updatedAt: now); } - public void SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) - { - Security = Security.SetExpiry(expiresAt); - UpdatedAt = now; - Version++; - } + public PasswordCredential SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) + => Next(security: Security.SetExpiry(expiresAt), updatedAt: now); - public void Revoke(DateTimeOffset now) + public PasswordCredential Revoke(DateTimeOffset now) { if (IsRevoked) - return; + return this; - Security = Security.Revoke(now); - UpdatedAt = now; - Version++; + return Next(security: Security.Revoke(now), updatedAt: now); } - public void RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan duration) - { - Security = Security.RegisterFailedAttempt(now, threshold, duration); - UpdatedAt = now; - Version++; - } + public PasswordCredential RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan duration) + => Next(security: Security.RegisterFailedAttempt(now, threshold, duration), updatedAt: now); - public void RegisterSuccessfulAuthentication(DateTimeOffset now) - { - Security = Security.RegisterSuccessfulAuthentication(); - UpdatedAt = now; - Version++; - } + public PasswordCredential RegisterSuccessfulAuthentication(DateTimeOffset now) + => Next(security: Security.RegisterSuccessfulAuthentication(), updatedAt: now); - public void BeginReset(DateTimeOffset now, TimeSpan validity) - { - Security = Security.BeginReset(now, validity); - UpdatedAt = now; - Version++; - } - - public void CompleteReset(DateTimeOffset now) - { - Security = Security.CompleteReset(now); + public PasswordCredential BeginReset(DateTimeOffset now, TimeSpan validity) + => Next(security: Security.BeginReset(now, validity), updatedAt: now); - UpdatedAt = now; - Version++; - } + public PasswordCredential CompleteReset(DateTimeOffset now) + => Next(security: Security.CompleteReset(now), updatedAt: now); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs deleted file mode 100644 index f356db6c..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal static class PasswordCredentialFactory -{ - public static PasswordCredential Create(TenantKey tenant, UserKey userKey, string secretHash, string? source, DateTimeOffset now) - => new PasswordCredential( - id: Guid.NewGuid(), - tenant: tenant, - userKey: userKey, - secretHash: secretHash, - security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { Source = source }, - createdAt: now, - updatedAt: null); -} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index e1d17fcc..706f8b69 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -31,15 +31,14 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r var hash = _passwordHasher.Hash(r.Password); - var credential = new PasswordCredential( + var credential = PasswordCredential.Create( id: null, tenant: tenant, userKey: userKey, secretHash: hash, security: CredentialSecurityState.Active(), metadata: new CredentialMetadata { LastUsedAt = _clock.UtcNow }, - _clock.UtcNow, - null); + _clock.UtcNow); await _credentialStore.AddAsync(tenant, credential, ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index c39578a4..1bdc34f1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; @@ -27,9 +28,7 @@ public CredentialManagementService( _clock = clock; } - public async Task GetAllAsync( - AccessContext context, - CancellationToken ct = default) + public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -52,7 +51,8 @@ public async Task GetAllAsync( RevokedAt = c.Security.RevokedAt, ResetRequestedAt = c.Security.ResetRequestedAt, LastUsedAt = c.Metadata.LastUsedAt, - Source = c.Metadata.Source + Source = c.Metadata.Source, + Version = c.Version, }) .ToArray(); @@ -73,11 +73,13 @@ public async Task AddAsync(AccessContext context, AddCreden var hash = _hasher.Hash(request.Secret); - var credential = PasswordCredentialFactory.Create( + var credential = PasswordCredential.Create( + id: null, tenant: context.ResourceTenant, userKey: subjectUser, secretHash: hash, - source: request.Source, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), now: now); await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); @@ -88,6 +90,7 @@ public async Task AddAsync(AccessContext context, AddCreden return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + // TODO: Invalidate sessions or tokens associated with the credential when changing secret or revoking public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -97,24 +100,33 @@ public async Task ChangeSecretAsync(AccessContext contex var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt); + var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).SingleOrDefault(); - if (credential is not PasswordCredential pwd) - return ChangeCredentialResult.Fail("credential_not_found"); + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); if (pwd.UserKey != subjectUser) - return ChangeCredentialResult.Fail("credential_not_found"); + throw new UAuthNotFoundException("credential_not_found"); - var verified = _hasher.Verify(pwd.SecretHash, request.CurrentSecret); - if (!verified) - return ChangeCredentialResult.Fail("invalid_credentials"); + if (context.IsSelfAction) + { + if (string.IsNullOrWhiteSpace(request.CurrentSecret)) + throw new UAuthNotFoundException("current_secret_required"); + + if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) + throw new UAuthConflictException("invalid_credentials"); + } + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); var oldVersion = pwd.Version; var newHash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(newHash, now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + var updated = pwd.ChangeSecret(newHash, now); + await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); - return ChangeCredentialResult.Success(credential.Type); + return ChangeCredentialResult.Success(pwd.Type); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs index 25dc159d..bc513d3a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs @@ -9,4 +9,5 @@ public interface ICredentialDescriptor CredentialType Type { get; } CredentialSecurityState Security { get; } CredentialMetadata Metadata { get; } + long Version { get; } } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index c0d2dca4..3e3e4116 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using Konscious.Security.Cryptography; namespace CodeBeam.UltimateAuth.Security.Argon2; @@ -17,7 +18,7 @@ public Argon2PasswordHasher(Argon2Options options) public string Hash(string password) { if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be null or empty.", nameof(password)); + throw new UAuthValidationException("Password cannot be null or empty."); var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs new file mode 100644 index 00000000..75b77844 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -0,0 +1,134 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ChangePasswordTests +{ + [Fact] + public async Task Change_password_with_correct_current_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var result = await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Change_password_with_wrong_current_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "wrong", + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_to_same_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "user" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_should_increment_version() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var before = await service.GetAllAsync(context); + var versionBefore = before.Credentials.Single().Version; + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var after = await service.GetAllAsync(context); + var versionAfter = after.Credentials.Single().Version; + + versionAfter.Should().Be(versionBefore + 1); + } + + [Fact] + public async Task Old_password_should_not_work_after_change() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var oldLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + oldLogin.IsSuccess.Should().BeFalse(); + + var newLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "newpass123" + }); + + newLogin.IsSuccess.Should().BeTrue(); + } + + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 93150800..9655fb39 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Flows; @@ -46,8 +47,9 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddUltimateAuthInMemoryTokens(); services.AddUltimateAuthInMemoryAuthenticationSecurity(); services.AddUltimateAuthAuthorizationInMemory(); - services.AddUltimateAuthAuthorizationReference(); services.AddUltimateAuthUsersReference(); + services.AddUltimateAuthAuthorizationReference(); + services.AddUltimateAuthCredentialsReference(); services.AddScoped(); services.AddScoped(); @@ -78,4 +80,10 @@ public IUserApplicationService GetUserApplicationService() var scope = Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } + + public ICredentialManagementService GetCredentialManagementService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } } From 3a2b79317ec06757a86580120ba7a0aa49f6b92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 5 Mar 2026 01:01:01 +0300 Subject: [PATCH 14/29] Complete Credential Reset & Added Tests --- .../Components/Dialogs/CredentialDialog.razor | 29 ++- .../Components/Dialogs/ResetDialog.razor | 73 ++++++ .../Components/Layout/MainLayout.razor | 2 +- .../Components/Pages/Login.razor | 7 + .../Components/Pages/Login.razor.cs | 24 ++ .../Components/Pages/ResetCredential.razor | 18 ++ .../Components/Pages/ResetCredential.razor.cs | 49 ++++ .../wwwroot/app.css | 50 +++- .../Components/UAuthFlowPageBase.cs | 2 + .../Services/ICredentialClient.cs | 8 +- .../Services/UAuthCredentialClient.cs | 16 +- .../Abstractions/Infrastructure/IClock.cs | 1 + .../Infrastructure/ITokenHasher.cs | 2 +- .../Issuers/INumericCodeGenerator.cs | 6 + .../IAuthenticationSecurityManager.cs | 5 +- .../IAuthenticationSecurityStateStore.cs | 2 + .../Defaults/UAuthActions.cs | 6 +- .../Defaults/UAuthSchemeDefaults.cs | 2 +- .../Security/AuthenticationSecurityState.cs | 195 +++++++++++++- .../Domain/Security/ResetCodeType.cs | 7 + .../UAuthAuthenticationExtension.cs | 2 +- .../AspNetCore/UAuthAuthenticationHandler.cs | 2 +- .../AuthenticationSecurityManager.cs | 65 +---- .../Endpoints/SessionEndpointHandler.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Flows/Login/LoginOrchestrator.cs | 8 +- .../{ => Generators}/JwtTokenGenerator.cs | 0 .../Generators/NumericCodeGenerator.cs | 15 ++ .../{ => Generators}/OpaqueTokenGenerator.cs | 3 +- .../Infrastructure/HmacSha256TokenHasher.cs | 6 +- .../Options/UAuthResetOptions.cs | 22 ++ .../Options/UAuthServerOptions.cs | 3 + ...nMemoryAuthenticationSecurityStateStore.cs | 31 ++- .../Endpoints/AuthorizationEndpointHandler.cs | 2 +- .../Dtos/CredentialDto.cs | 3 - .../Dtos/CredentialSecurityState.cs | 143 ---------- .../Request/BeginCredentialResetRequest.cs | 9 +- .../Request/CompleteCredentialResetRequest.cs | 7 +- .../Responses/BeginCredentialResetResult.cs | 7 + .../InMemoryCredentialStore.cs | 18 +- .../Domain/PasswordCredential.cs | 12 - .../Endpoints/CredentialEndpointHandler.cs | 28 +- .../Services/CredentialManagementService.cs | 165 ++++++++++-- .../Services/ICredentialManagementService.cs | 2 +- .../Policies/RequireActiveUserPolicy.cs | 7 +- .../Policies/RequireAuthenticatedPolicy.cs | 7 +- .../Policies/RequireSelfOrAdminPolicy.cs | 10 +- .../Policies/RequireSelfPolicy.cs | 6 +- .../Endpoints/UserEndpointHandler.cs | 5 +- .../Credentials/ChangePasswordTests.cs | 3 +- .../Credentials/ResetPasswordTests.cs | 245 ++++++++++++++++++ .../Helpers/TestAuthRuntime.cs | 5 +- .../Helpers/TestClock.cs | 25 ++ .../UserIdentifierApplicationServiceTests.cs | 2 +- 54 files changed, 1044 insertions(+), 333 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs rename src/{CodeBeam.UltimateAuth.Server => CodeBeam.UltimateAuth.Core}/Defaults/UAuthActions.cs (94%) rename src/{CodeBeam.UltimateAuth.Server => CodeBeam.UltimateAuth.Core}/Defaults/UAuthSchemeDefaults.cs (67%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Generators}/JwtTokenGenerator.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/{ => Generators}/OpaqueTokenGenerator.cs (79%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor index d9f9a0d9..3d43db85 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -14,12 +14,23 @@ - - - - - Change Password - + + + + + + + + + + + + + + + Change Password + + @@ -33,6 +44,9 @@ private string? _oldPassword; private string? _newPassword; private string? _newPasswordCheck; + private bool _passwordMode1 = false; + private bool _passwordMode2 = false; + private bool _passwordMode3 = true; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; @@ -62,7 +76,6 @@ return; } - if (_newPassword != _newPasswordCheck) { Snackbar.Add("New password and check do not match", Severity.Error); @@ -87,7 +100,7 @@ } } - private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : null; + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; private void Submit() => MudDialog.Close(DialogResult.Ok(true)); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor new file mode 100644 index 00000000..2b94e413 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,73 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Reset Credential + + + + + This is a demonstration of how to implement a credential reset flow. + In a production application, you should use reset token or code in email, SMS etc. verification steps. + + + Reset request always returns ok even with not found users due to security reasons. + + + Request Reset + @if (_resetRequested) + { + Your reset code is: (Copy it before next step) + @_resetCode + Use Reset Code + } + + + + Cancel + OK + + + +@code { + private bool _resetRequested = false; + private string? _resetCode; + private string? _identifier; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task RequestResetAsync() + { + var request = new BeginCredentialResetRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier ?? string.Empty + }; + + var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 336f6537..937b438c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -6,7 +6,7 @@ - UltimateAuth + UltimateAuth Blazor Server Sample diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index 1db4091f..658f6385 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -7,6 +7,7 @@ @inject ISnackbar Snackbar @inject IUAuthClientProductInfoProvider ClientProductInfoProvider @inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService @@ -111,6 +112,12 @@ Programmatic Login Login programmatically as admin/admin. + + + + + Forgot Password + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index b07fbb46..4a0cbb91 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; using MudBlazor; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; @@ -159,6 +160,29 @@ private void UpdateRemaining() } } + private async Task OpenResetDialog() + { + await DialogService.ShowAsync("Reset Credentials", GetDialogParameters(), GetDialogOptions()); + } + + private DialogOptions GetDialogOptions() + { + return new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + public override void Dispose() { base.Dispose(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor new file mode 100644 index 00000000..753878b8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor @@ -0,0 +1,18 @@ +@page "/reset" +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + + + + + + Change Password + + + + \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs new file mode 100644 index 00000000..fd66181e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -0,0 +1,49 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class ResetCredential +{ + private MudForm _form = null!; + private string? _code; + private string? _newPassword; + private string? _newPasswordCheck; + + private async Task ResetPasswordAsync() + { + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + var request = new CompleteCredentialResetRequest + { + ResetToken = _code, + NewSecret = _newPassword ?? string.Empty, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + }; + + var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to reset credential. Please try again.", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css index 06a588be..192a5e47 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -68,7 +68,7 @@ h1:focus { } .uauth-login-paper { - min-height: 60vh; + min-height: 70vh; } .uauth-login-paper.mud-theme-primary { @@ -81,11 +81,7 @@ h1:focus { } .uauth-logo-slide { - transition: transform 1s cubic-bezier(.4, 0, .2, 1); -} - -.uauth-login-paper:hover .uauth-logo-slide { - transform: translateY(200px) rotateY(360deg); + animation: uauth-logo-float 30s ease-in-out infinite; } .uauth-text-transform-none .mud-button { @@ -95,3 +91,45 @@ h1:focus { .uauth-dialog { min-height: 62vh; } + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs index 34a3ffc6..f4723276 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthFlowPageBase.cs @@ -13,6 +13,7 @@ public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase protected AuthFlowPayload? UAuthPayload { get; private set; } protected string? ReturnUrl { get; private set; } protected bool ShouldFocus { get; private set; } + protected string? Identifier { get; private set; } protected virtual bool ClearQueryAfterParse => true; @@ -39,6 +40,7 @@ protected override void OnParametersSet() ShouldFocus = query.TryGetValue("focus", out var focus) && focus == "1"; ReturnUrl = query.TryGetValue("returnUrl", out var ru) ? ru.ToString() : null; + Identifier = query.TryGetValue("identifier", out var id) ? id.ToString() : null; UAuthPayload = null; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs index af65bc96..534efb0a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs @@ -10,14 +10,14 @@ public interface ICredentialClient Task> AddMyAsync(AddCredentialRequest request); Task> ChangeMyAsync(ChangeCredentialRequest request); Task RevokeMyAsync(RevokeCredentialRequest request); - Task BeginResetMyAsync(BeginCredentialResetRequest request); - Task CompleteResetMyAsync(CompleteCredentialResetRequest request); + Task> BeginResetMyAsync(BeginCredentialResetRequest request); + Task> CompleteResetMyAsync(CompleteCredentialResetRequest request); Task> GetUserAsync(UserKey userKey); Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); Task ActivateUserAsync(UserKey userKey); - Task BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request); - Task CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request); + Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request); + Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request); Task DeleteUserAsync(UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 6fbd90d4..b53fc6b2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -44,16 +44,16 @@ public async Task RevokeMyAsync(RevokeCredentialRequest request) return UAuthResultMapper.From(raw); } - public async Task BeginResetMyAsync(BeginCredentialResetRequest request) + public async Task> BeginResetMyAsync(BeginCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/reset/begin"), request); - return UAuthResultMapper.From(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetMyAsync(CompleteCredentialResetRequest request) + public async Task> CompleteResetMyAsync(CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/reset/complete"), request); - return UAuthResultMapper.From(raw); + return UAuthResultMapper.FromJson(raw); } @@ -81,16 +81,16 @@ public async Task ActivateUserAsync(UserKey userKey) return UAuthResultMapper.From(raw); } - public async Task BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request) + public async Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); - return UAuthResultMapper.From(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request) + public async Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); - return UAuthResultMapper.From(raw); + return UAuthResultMapper.FromJson(raw); } public async Task DeleteUserAsync(UserKey userKey) diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs index 71e7a186..0d40c708 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -4,6 +4,7 @@ /// Provides an abstracted time source for the system. /// Used to improve testability and ensure consistent time handling. /// +// TODO: Add UnixTimeSeconds, TimeZone-aware Now, etc. if needed. public interface IClock { DateTimeOffset UtcNow { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs index 8112e451..1096f980 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs @@ -7,5 +7,5 @@ public interface ITokenHasher { string Hash(string plaintext); - bool Verify(string plaintext, string hash); + bool Verify(string hash, string plaintext); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs new file mode 100644 index 00000000..73b4cfc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface INumericCodeGenerator +{ + string Generate(int digits = 6); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs index 9d5f665a..1e12f065 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityManager.cs @@ -8,7 +8,6 @@ public interface IAuthenticationSecurityManager { Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default); - Task RegisterFailureAsync(AuthenticationSecurityState state, DateTimeOffset now, CancellationToken ct = default); - Task RegisterSuccessAsync(AuthenticationSecurityState state, CancellationToken ct = default); - Task UnlockAsync(AuthenticationSecurityState state, CancellationToken ct = default); + Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs index 8ba53834..fef7743c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Security/IAuthenticationSecurityStateStore.cs @@ -11,4 +11,6 @@ public interface IAuthenticationSecurityStateStore Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default); Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 4c7e28a7..2beb517c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthActions { @@ -62,9 +62,9 @@ public static class Credentials public const string RevokeSelf = "credentials.revoke.self"; public const string RevokeAdmin = "credentials.revoke.admin"; public const string ActivateSelf = "credentials.activate.self"; - public const string BeginResetSelf = "credentials.beginreset.self"; + public const string BeginResetAnonymous = "credentials.beginreset.anonymous"; public const string BeginResetAdmin = "credentials.beginreset.admin"; - public const string CompleteResetSelf = "credentials.completereset.self"; + public const string CompleteResetAnonymous = "credentials.completereset.anonymous"; public const string CompleteResetAdmin = "credentials.completereset.admin"; public const string DeleteAdmin = "credentials.delete.admin"; } diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs similarity index 67% rename from src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs index bfcea56c..98c5d1fb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthSchemeDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Defaults; +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthSchemeDefaults { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs index 4aaed649..501db9ae 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -4,6 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Security; +// TODO: Do not store reset token hash in db. public sealed class AuthenticationSecurityState { public Guid Id { get; } @@ -17,9 +18,16 @@ public sealed class AuthenticationSecurityState public DateTimeOffset? LockedUntil { get; } public bool RequiresReauthentication { get; } + public DateTimeOffset? ResetRequestedAt { get; } + public DateTimeOffset? ResetExpiresAt { get; } + public DateTimeOffset? ResetConsumedAt { get; } + public string? ResetTokenHash { get; } + public int ResetAttempts { get; } + public long SecurityVersion { get; } public bool IsLocked(DateTimeOffset now) => LockedUntil.HasValue && LockedUntil > now; + public bool HasResetRequest => ResetRequestedAt is not null; private AuthenticationSecurityState( Guid id, @@ -31,6 +39,11 @@ private AuthenticationSecurityState( DateTimeOffset? lastFailedAt, DateTimeOffset? lockedUntil, bool requiresReauthentication, + DateTimeOffset? resetRequestedAt, + DateTimeOffset? resetExpiresAt, + DateTimeOffset? resetConsumedAt, + string? resetTokenHash, + int resetAttempts, long securityVersion) { if (id == Guid.Empty) @@ -51,6 +64,11 @@ private AuthenticationSecurityState( LastFailedAt = lastFailedAt; LockedUntil = lockedUntil; RequiresReauthentication = requiresReauthentication; + ResetRequestedAt = resetRequestedAt; + ResetExpiresAt = resetExpiresAt; + ResetConsumedAt = resetConsumedAt; + ResetTokenHash = resetTokenHash; + ResetAttempts = resetAttempts; SecurityVersion = securityVersion < 0 ? 0 : securityVersion; } @@ -65,6 +83,11 @@ public static AuthenticationSecurityState CreateAccount(TenantKey tenant, UserKe lastFailedAt: null, lockedUntil: null, requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, securityVersion: 0); public static AuthenticationSecurityState CreateFactor(TenantKey tenant, UserKey userKey, CredentialType type, Guid? id = null) @@ -78,6 +101,11 @@ public static AuthenticationSecurityState CreateFactor(TenantKey tenant, UserKey lastFailedAt: null, lockedUntil: null, requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, securityVersion: 0); /// @@ -105,6 +133,11 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n lastFailedAt: null, lockedUntil: LockedUntil, requiresReauthentication: RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, securityVersion: SecurityVersion + 1); } @@ -140,7 +173,12 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres failedAttempts: nextCount, lastFailedAt: now, lockedUntil: nextLockedUntil, - requiresReauthentication: RequiresReauthentication, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, securityVersion: SecurityVersion + 1); } @@ -157,7 +195,12 @@ public AuthenticationSecurityState RegisterSuccess() failedAttempts: 0, lastFailedAt: null, lockedUntil: null, - requiresReauthentication: RequiresReauthentication, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, securityVersion: SecurityVersion + 1); /// @@ -173,7 +216,12 @@ public AuthenticationSecurityState Unlock() failedAttempts: 0, lastFailedAt: null, lockedUntil: null, - requiresReauthentication: RequiresReauthentication, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, securityVersion: SecurityVersion + 1); public AuthenticationSecurityState LockUntil(DateTimeOffset until, bool overwriteIfShorter = false) @@ -198,6 +246,11 @@ public AuthenticationSecurityState LockUntil(DateTimeOffset until, bool overwrit LastFailedAt, lockedUntil: next, RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, SecurityVersion + 1); } @@ -216,6 +269,11 @@ public AuthenticationSecurityState RequireReauthentication() LastFailedAt, LockedUntil, requiresReauthentication: true, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, securityVersion: SecurityVersion + 1); } @@ -234,6 +292,137 @@ public AuthenticationSecurityState ClearReauthentication() LastFailedAt, LockedUntil, requiresReauthentication: false, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public bool HasActiveReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + return false; + + if (ResetConsumedAt is not null) + return false; + + if (ResetExpiresAt is not null && ResetExpiresAt <= now) + return false; + + return true; + } + + public bool IsResetExpired(DateTimeOffset now) + { + return ResetExpiresAt is not null && ResetExpiresAt <= now; + } + + public AuthenticationSecurityState BeginReset(string tokenHash, DateTimeOffset now, TimeSpan validity) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + throw new UAuthValidationException("reset_token_required"); + + if (HasActiveReset(now)) + throw new UAuthConflictException("reset_already_active"); + + var expires = now.Add(validity); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + resetRequestedAt: now, + resetExpiresAt: expires, + resetConsumedAt: null, + resetTokenHash: tokenHash, + resetAttempts: 0, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState RegisterResetFailure(DateTimeOffset now, int maxAttempts) + { + if (IsResetExpired(now)) + return ClearReset(); + + var next = ResetAttempts + 1; + + if (next >= maxAttempts) + { + return ClearReset(); + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + next, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ConsumeReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + throw new UAuthConflictException("reset_not_requested"); + + if (ResetConsumedAt is not null) + throw new UAuthConflictException("reset_already_used"); + + if (IsResetExpired(now)) + throw new UAuthConflictException("reset_expired"); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + now, + null, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReset() + { + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + null, + null, + null, + null, + 0, securityVersion: SecurityVersion + 1); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs new file mode 100644 index 00000000..50d23e2c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum ResetCodeType +{ + Token = 0, + Code = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index 59463779..5a8e66ff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Authentication; namespace CodeBeam.UltimateAuth.Server.Authentication; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index 4072266a..716f01d0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs index bcda5f9e..5c531ec4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Server.Options; @@ -20,6 +21,8 @@ public AuthenticationSecurityManager(IAuthenticationSecurityStateStore store, IO public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); if (state is not null) @@ -32,6 +35,8 @@ public async Task GetOrCreateAccountAsync(TenantKey public async Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var state = await _store.GetAsync(tenant, userKey, AuthenticationSecurityScope.Factor, type, ct); if (state is not null) @@ -42,63 +47,15 @@ public async Task GetOrCreateFactorAsync(TenantKey return created; } - public async Task RegisterFailureAsync(AuthenticationSecurityState state, DateTimeOffset now, CancellationToken ct = default) - { - var loginOptions = _options.Login; - - var threshold = loginOptions.MaxFailedAttempts; - var duration = loginOptions.LockoutDuration; - var window = loginOptions.FailureWindow; - var extendLock = loginOptions.ExtendLockOnFailure; - - if (window > TimeSpan.Zero) - { - state = state.ResetFailuresIfWindowExpired(now, window); - } - - var updated = state.RegisterFailure(now, threshold, duration, extendLock: extendLock); - await PersistWithRetryAsync(updated, state.SecurityVersion, ct); - return updated; - } - - public async Task RegisterSuccessAsync(AuthenticationSecurityState state, CancellationToken ct = default) - { - var updated = state.RegisterSuccess(); - await PersistWithRetryAsync(updated, state.SecurityVersion, ct); - return updated; - } - - public async Task UnlockAsync(AuthenticationSecurityState state, CancellationToken ct = default) + public Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default) { - var updated = state.Unlock(); - await PersistWithRetryAsync(updated, state.SecurityVersion, ct); - return updated; + return _store.UpdateAsync(updated, expectedVersion, ct); } - private async Task PersistWithRetryAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct) + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) { - const int maxRetries = 3; - - for (var i = 0; i < maxRetries; i++) - { - try - { - await _store.UpdateAsync(updated, expectedVersion, ct); - return; - } - catch (InvalidOperationException) - { - if (i == maxRetries - 1) - throw; - - var current = await _store.GetAsync(updated.Tenant, updated.UserKey, updated.Scope, updated.CredentialType, ct); - - if (current is null) - throw; + ct.ThrowIfCancellationRequested(); - updated = current; - expectedVersion = current.SecurityVersion; - } - } + return _store.DeleteAsync(tenant, userKey, scope, credentialType, ct); } -} \ No newline at end of file +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs index d017741e..3fb62d3e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index bc54e429..3240f676 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; @@ -16,7 +17,6 @@ using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -71,6 +71,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(sp => diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 8c2a1889..30d3f91a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -131,7 +131,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { if (userKey is not null && userExists && factorState is not null) { - factorState = await _authenticationSecurityManager.RegisterFailureAsync(factorState, now, ct); + var securityVersion = factorState.SecurityVersion; + factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); + await _authenticationSecurityManager.UpdateAsync(factorState, securityVersion, ct); DateTimeOffset? lockedUntil = null; int? remainingAttempts = null; @@ -175,7 +177,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req // After this point, the login is successful. We can reset any failure counts and proceed to create a session. if (factorState is not null) { - await _authenticationSecurityManager.RegisterSuccessAsync(factorState, ct); + var version = factorState.SecurityVersion; + factorState = factorState.RegisterSuccess(); + await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); } var claims = await _claimsProvider.GetClaimsAsync(request.Tenant, userKey.Value, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/JwtTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs new file mode 100644 index 00000000..52547057 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class NumericCodeGenerator : INumericCodeGenerator +{ + public string Generate(int digits = 6) + { + var max = (int)Math.Pow(10, digits); + var number = RandomNumberGenerator.GetInt32(max); + + return number.ToString($"D{digits}"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs similarity index 79% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs index ba7d89f8..9dee1407 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OpaqueTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; using System.Security.Cryptography; @@ -17,5 +18,5 @@ public OpaqueTokenGenerator(IOptions options) public string Generate() => GenerateBytes(_options.OpaqueIdBytes); public string GenerateJwtId() => GenerateBytes(16); - private static string GenerateBytes(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)); + private static string GenerateBytes(int bytes) => WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(bytes)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs index 31b36020..941c23d1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -24,12 +24,12 @@ public string Hash(string plaintext) return Convert.ToBase64String(hash); } - public bool Verify(string plaintext, string hash) + public bool Verify(string hash, string plaintext) { var computed = Hash(plaintext); return CryptographicOperations.FixedTimeEquals( - Convert.FromBase64String(computed), - Convert.FromBase64String(hash)); + Convert.FromBase64String(hash), + Convert.FromBase64String(computed)); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs new file mode 100644 index 00000000..9abeec8d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs @@ -0,0 +1,22 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthResetOptions +{ + public TimeSpan TokenValidity { get; set; } = TimeSpan.FromMinutes(30); + public TimeSpan CodeValidity { get; set; } = TimeSpan.FromMinutes(10); + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the length for numeric reset codes. Does not affect token-based resets. + /// Default is 6, which means the code will be a 6-digit number. + /// + public int CodeLength { get; set; } = 6; + + internal UAuthResetOptions Clone() => new() + { + TokenValidity = TokenValidity, + CodeValidity = CodeValidity, + MaxAttempts = MaxAttempts, + CodeLength = CodeLength + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 17dbebe1..4d4af86b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -74,6 +74,8 @@ public sealed class UAuthServerOptions public UAuthResponseOptions AuthResponse { get; init; } = new(); + public UAuthResetOptions ResetCredential { get; init; } = new(); + public UAuthHubServerOptions Hub { get; set; } = new(); /// @@ -138,6 +140,7 @@ internal UAuthServerOptions Clone() PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), + ResetCredential = ResetCredential.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), UserIdentifiers = UserIdentifiers.Clone(), diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs index 8d9dcfec..8c7e25ab 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; using System.Collections.Concurrent; @@ -30,12 +31,12 @@ public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = d var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); if (!_index.TryAdd(key, state.Id)) - throw new InvalidOperationException("security_state_already_exists"); + throw new UAuthConflictException("security_state_already_exists"); if (!_byId.TryAdd(state.Id, state)) { _index.TryRemove(key, out _); - throw new InvalidOperationException("security_state_add_failed"); + throw new UAuthConflictException("security_state_add_failed"); } return Task.CompletedTask; @@ -45,13 +46,33 @@ public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, { ct.ThrowIfCancellationRequested(); + var key = (state.Tenant, state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryGetValue(key, out var id) || id != state.Id) + throw new UAuthConflictException("security_state_index_corrupted"); + if (!_byId.TryGetValue(state.Id, out var current)) - throw new InvalidOperationException("security_state_not_found"); + throw new UAuthNotFoundException("security_state_not_found"); if (current.SecurityVersion != expectedVersion) - throw new InvalidOperationException("security_state_version_conflict"); + throw new UAuthConflictException("security_state_version_conflict"); + + if (!_byId.TryUpdate(state.Id, state, current)) + throw new UAuthConflictException("security_state_update_conflict"); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenant, userKey, scope, credentialType); + + if (!_index.TryRemove(key, out var id)) + return Task.CompletedTask; - _byId[state.Id] = state; + _byId.TryRemove(id, out _); return Task.CompletedTask; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index a86fb67b..f35f7126 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs index 5d3e4b07..2c3df24a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDto.cs @@ -13,12 +13,9 @@ public sealed record CredentialDto public DateTimeOffset? LastUsedAt { get; init; } - public DateTimeOffset? LockedUntil { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? RevokedAt { get; init; } - public DateTimeOffset? ResetRequestedAt { get; init; } public string? Source { get; init; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index ffbd6ccb..3a06c757 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -5,34 +5,16 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { public DateTimeOffset? RevokedAt { get; } - public DateTimeOffset? LockedUntil { get; } public DateTimeOffset? ExpiresAt { get; } - public DateTimeOffset? ResetRequestedAt { get; } - public DateTimeOffset? ResetExpiresAt { get; } - public DateTimeOffset? ResetConsumedAt { get; } - public int FailedAttemptCount { get; } - public DateTimeOffset? LastFailedAt { get; } public Guid SecurityStamp { get; } public CredentialSecurityState( DateTimeOffset? revokedAt = null, - DateTimeOffset? lockedUntil = null, DateTimeOffset? expiresAt = null, - DateTimeOffset? resetRequestedAt = null, - DateTimeOffset? resetExpiresAt = null, - DateTimeOffset? resetConsumedAt = null, - int failedAttemptCount = 0, - DateTimeOffset? lastFailedAt = null, Guid securityStamp = default) { RevokedAt = revokedAt; - LockedUntil = lockedUntil; ExpiresAt = expiresAt; - ResetRequestedAt = resetRequestedAt; - ResetExpiresAt = resetExpiresAt; - ResetConsumedAt = resetConsumedAt; - FailedAttemptCount = failedAttemptCount; - LastFailedAt = lastFailedAt; SecurityStamp = securityStamp; } @@ -41,23 +23,9 @@ public CredentialSecurityStatus Status(DateTimeOffset now) if (RevokedAt is not null) return CredentialSecurityStatus.Revoked; - if (LockedUntil is not null && LockedUntil > now) - return CredentialSecurityStatus.Locked; - if (ExpiresAt is not null && ExpiresAt <= now) return CredentialSecurityStatus.Expired; - if (ResetRequestedAt is not null) - { - if (ResetConsumedAt is not null) - return CredentialSecurityStatus.Active; - - if (ResetExpiresAt is not null && ResetExpiresAt <= now) - return CredentialSecurityStatus.Active; - - return CredentialSecurityStatus.ResetRequested; - } - return CredentialSecurityStatus.Active; } @@ -70,13 +38,7 @@ public static CredentialSecurityState Active(Guid? securityStamp = null) { return new CredentialSecurityState( revokedAt: null, - lockedUntil: null, expiresAt: null, - resetRequestedAt: null, - resetExpiresAt: null, - resetConsumedAt: null, - failedAttemptCount: 0, - lastFailedAt: null, securityStamp: securityStamp ?? Guid.NewGuid() ); } @@ -91,13 +53,7 @@ public CredentialSecurityState Revoke(DateTimeOffset now) return new CredentialSecurityState( revokedAt: now, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - resetExpiresAt: ResetExpiresAt, - resetConsumedAt: ResetConsumedAt, - failedAttemptCount: FailedAttemptCount, - lastFailedAt: LastFailedAt, securityStamp: Guid.NewGuid() ); } @@ -113,13 +69,7 @@ public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt) return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, expiresAt: expiresAt, - resetRequestedAt: ResetRequestedAt, - resetExpiresAt: ResetExpiresAt, - resetConsumedAt: ResetConsumedAt, - failedAttemptCount: FailedAttemptCount, - lastFailedAt: LastFailedAt, securityStamp: EnsureStamp(SecurityStamp) ); } @@ -130,101 +80,8 @@ public CredentialSecurityState RotateStamp() { return new CredentialSecurityState( revokedAt: RevokedAt, - lockedUntil: LockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - resetExpiresAt: ResetExpiresAt, - resetConsumedAt: ResetConsumedAt, - failedAttemptCount: FailedAttemptCount, - lastFailedAt: LastFailedAt, - securityStamp: Guid.NewGuid() - ); - } - - public CredentialSecurityState RegisterSuccessfulAuthentication() - { - return new CredentialSecurityState( - revokedAt: RevokedAt, - lockedUntil: null, - expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - resetExpiresAt: ResetExpiresAt, - resetConsumedAt: ResetConsumedAt, - failedAttemptCount: 0, - lastFailedAt: null, - securityStamp: EnsureStamp(SecurityStamp) - ); - } - - public CredentialSecurityState RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan lockoutDuration) - { - if (threshold <= 0) - throw new UAuthValidationException(nameof(threshold)); - - var failed = FailedAttemptCount + 1; - - var newLockedUntil = LockedUntil; - - if (failed >= threshold) - { - var candidate = now.Add(lockoutDuration); - - if (LockedUntil is null || candidate > LockedUntil) - newLockedUntil = candidate; - } - - return new CredentialSecurityState( - revokedAt: RevokedAt, - lockedUntil: newLockedUntil, - expiresAt: ExpiresAt, - resetRequestedAt: ResetRequestedAt, - resetExpiresAt: ResetExpiresAt, - resetConsumedAt: ResetConsumedAt, - failedAttemptCount: failed, - lastFailedAt: now, - securityStamp: EnsureStamp(SecurityStamp) - ); - } - - public CredentialSecurityState BeginReset(DateTimeOffset now, TimeSpan validity) - { - if (validity <= TimeSpan.Zero) - throw new UAuthValidationException("credential_lockout_threshold_invalid"); - - return new CredentialSecurityState( - revokedAt: RevokedAt, - lockedUntil: LockedUntil, expiresAt: ExpiresAt, - resetRequestedAt: now, - resetExpiresAt: now.Add(validity), - resetConsumedAt: null, - failedAttemptCount: FailedAttemptCount, - lastFailedAt: LastFailedAt, securityStamp: Guid.NewGuid() ); } - - public CredentialSecurityState CompleteReset(DateTimeOffset now, bool rotateStamp = true) - { - if (ResetRequestedAt is null) - throw new UAuthValidationException("reset_not_requested"); - - if (ResetConsumedAt is not null) - throw new UAuthValidationException("reset_already_consumed"); - - if (ResetExpiresAt is not null && ResetExpiresAt <= now) - throw new UAuthValidationException("reset_expired"); - - return new CredentialSecurityState( - revokedAt: RevokedAt, - lockedUntil: null, - expiresAt: ExpiresAt, - resetRequestedAt: null, - resetExpiresAt: null, - resetConsumedAt: now, - failedAttemptCount: 0, - lastFailedAt: null, - securityStamp: rotateStamp ? Guid.NewGuid() : EnsureStamp(SecurityStamp) - ); - } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs index 98574831..dd96bf98 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -1,7 +1,12 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record BeginCredentialResetRequest { - public Guid Id { get; init; } + public string Identifier { get; init; } = default!; + public CredentialType CredentialType { get; set; } = CredentialType.Password; + public ResetCodeType ResetCodeType { get; set; } public string? Channel { get; init; } + public TimeSpan? Validity { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs index b9158e81..ec7abac5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -1,8 +1,11 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CompleteCredentialResetRequest { - public Guid Id { get; init; } + public string? Identifier { get; init; } + public CredentialType CredentialType { get; set; } = CredentialType.Password; public string? ResetToken { get; init; } public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs new file mode 100644 index 00000000..c7f399c8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record BeginCredentialResetResult +{ + public string? Token { get; init; } + public DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index e46aaca1..933ea00c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -64,7 +64,7 @@ public Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedV if (current.Version != expectedVersion) throw new UAuthConflictException("credential_version_conflict"); - _store[key] = pwd; + _store.TryUpdate(key, pwd, current); return Task.CompletedTask; } @@ -84,7 +84,8 @@ public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revo if (credential.IsRevoked) return Task.CompletedTask; - credential.Revoke(revokedAt); + var updated = credential.Revoke(revokedAt); + _store[key] = updated; return Task.CompletedTask; } @@ -108,7 +109,10 @@ public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, Da } if (!credential.IsRevoked) - credential.Revoke(now); + { + var updated = credential.Revoke(now); + _store[key] = updated; + } return Task.CompletedTask; } @@ -123,13 +127,7 @@ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMod { ct.ThrowIfCancellationRequested(); - await DeleteAsync( - tenant, - credential.Id, - mode, - now, - credential.Version, - ct); + await DeleteAsync(tenant, credential.Id, mode, now, credential.Version, ct); } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 356ffe67..1d890a8d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -116,16 +116,4 @@ public PasswordCredential Revoke(DateTimeOffset now) return Next(security: Security.Revoke(now), updatedAt: now); } - - public PasswordCredential RegisterFailedAttempt(DateTimeOffset now, int threshold, TimeSpan duration) - => Next(security: Security.RegisterFailedAttempt(now, threshold, duration), updatedAt: now); - - public PasswordCredential RegisterSuccessfulAuthentication(DateTimeOffset now) - => Next(security: Security.RegisterSuccessfulAuthentication(), updatedAt: now); - - public PasswordCredential BeginReset(DateTimeOffset now, TimeSpan validity) - => Next(security: Security.BeginReset(now, validity), updatedAt: now); - - public PasswordCredential CompleteReset(DateTimeOffset now) - => Next(security: Security.CompleteReset(now), updatedAt: now); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 742950b7..76898d71 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -1,7 +1,7 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; @@ -89,36 +89,36 @@ public async Task RevokeAsync(HttpContext ctx) public async Task BeginResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.BeginResetSelf, + action: UAuthActions.Credentials.BeginResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } public async Task CompleteResetAsync(HttpContext ctx) { - if (!TryGetSelf(out var flow, out var error)) - return error!; + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Credentials.CompleteResetSelf, + action: UAuthActions.Credentials.CompleteResetAnonymous, resource: "credentials", - resourceId: flow.UserKey!.Value); + resourceId: request.Identifier); - await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); } public async Task GetAllAdminAsync(UserKey userKey, HttpContext ctx) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 1bdc34f1..09c2aea4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -6,25 +6,47 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Credentials.Reference; +// TODO: Add unlock credential factor as admin action. internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService { private readonly IAccessOrchestrator _accessOrchestrator; private readonly ICredentialStore _credentials; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; + private readonly IOpaqueTokenGenerator _tokenGenerator; + private readonly INumericCodeGenerator _numericCodeGenerator; private readonly IUAuthPasswordHasher _hasher; + private readonly ITokenHasher _tokenHasher; + private readonly ILoginIdentifierResolver _identifierResolver; + private readonly UAuthServerOptions _options; private readonly IClock _clock; public CredentialManagementService( IAccessOrchestrator accessOrchestrator, ICredentialStore credentials, + IAuthenticationSecurityManager authenticationSecurityManager, + IOpaqueTokenGenerator tokenGenerator, + INumericCodeGenerator numericCodeGenerator, IUAuthPasswordHasher hasher, + ITokenHasher tokenHasher, + ILoginIdentifierResolver identifierResolver, + IOptions options, IClock clock) { _accessOrchestrator = accessOrchestrator; _credentials = credentials; + _authenticationSecurityManager = authenticationSecurityManager; + _tokenGenerator = tokenGenerator; + _numericCodeGenerator = numericCodeGenerator; _hasher = hasher; + _tokenHasher = tokenHasher; + _identifierResolver = identifierResolver; + _options = options.Value; _clock = clock; } @@ -46,10 +68,8 @@ public async Task GetAllAsync(AccessContext context, Cance Id = c.Id, Type = c.Type, Status = c.Security.Status(now), - LockedUntil = c.Security.LockedUntil, ExpiresAt = c.Security.ExpiresAt, RevokedAt = c.Security.RevokedAt, - ResetRequestedAt = c.Security.ResetRequestedAt, LastUsedAt = c.Metadata.LastUsedAt, Source = c.Metadata.Source, Version = c.Version, @@ -150,8 +170,8 @@ public async Task RevokeAsync(AccessContext context, Rev return CredentialActionResult.Fail("credential_not_found"); var oldVersion = pwd.Version; - pwd.Revoke(now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + var updated = pwd.Revoke(now); + await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); return CredentialActionResult.Success(); }); @@ -159,28 +179,57 @@ public async Task RevokeAsync(AccessContext context, Rev return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default) + public async Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { - var subjectUser = context.GetTargetUserKey(); + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + var now = _clock.UtcNow; + var validity = request.Validity ?? _options.ResetCredential.TokenValidity; - var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); - if (credential is not PasswordCredential pwd) - return CredentialActionResult.Fail("credential_not_found"); + if (resolution?.UserKey is not UserKey userKey) + { + return new BeginCredentialResetResult + { + Token = null, + ExpiresAt = now.Add(validity) + }; + } - if (pwd.UserKey != subjectUser) - return CredentialActionResult.Fail("credential_not_found"); + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); - var oldVersion = pwd.Version; - //pwd.BeginReset(now, request.Validity); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + string token; - return CredentialActionResult.Success(); + if (request.ResetCodeType == ResetCodeType.Token) + { + token = _tokenGenerator.Generate(); + } + else if (request.ResetCodeType == ResetCodeType.Code) + { + token = _numericCodeGenerator.Generate(_options.ResetCredential.CodeLength); + } + else + { + throw new UAuthValidationException("invalid_reset_code_type"); + } + + var tokenHash = _tokenHasher.Hash(token); + + var updatedState = state.BeginReset(tokenHash, now, validity); + await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt); + + return new BeginCredentialResetResult + { + Token = token, + ExpiresAt = now.Add(validity) + }; }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -192,24 +241,88 @@ public async Task CompleteResetAsync(AccessContext conte var cmd = new AccessCommand(async innerCt => { - var subjectUser = context.GetTargetUserKey(); + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + + if (string.IsNullOrWhiteSpace(request.ResetToken)) + throw new UAuthValidationException("reset_token_required"); + + if (string.IsNullOrWhiteSpace(request.NewSecret)) + throw new UAuthValidationException("new_secret_required"); + var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); - if (credential is not PasswordCredential pwd) - return CredentialActionResult.Fail("credential_not_found"); + if (resolution?.UserKey is not UserKey userKey) + { + // Enumeration protection + return CredentialActionResult.Success(); + } - if (pwd.UserKey != subjectUser) - return CredentialActionResult.Fail("credential_not_found"); + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + + if (!state.HasActiveReset(now)) + throw new UAuthConflictException("reset_request_not_active"); + + if (state.IsResetExpired(now)) + { + var version2 = state.SecurityVersion; + var cleared = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + throw new UAuthConflictException("reset_expired"); + } + + if (!_tokenHasher.Verify(state.ResetTokenHash!, request.ResetToken)) + { + var version = state.SecurityVersion; + var failed = state.RegisterResetFailure(now, _options.ResetCredential.MaxAttempts); + await _authenticationSecurityManager.UpdateAsync(failed, version, innerCt); + throw new UAuthConflictException("invalid_reset_token"); + } + + var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, userKey, innerCt); + var pwd = credentials.OfType().FirstOrDefault(c => c.Security.IsUsable(now)); + + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); + + var version3 = state.SecurityVersion; + state = state.ConsumeReset(now); + await _authenticationSecurityManager.UpdateAsync(state, version3, innerCt); var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + + await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CancelResetAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, CredentialType.Password, innerCt); - pwd.CompleteReset(now); + if (!state.HasActiveReset(_clock.UtcNow)) + return CredentialActionResult.Success(); - var hash = _hasher.Hash(request.NewSecret); - pwd.ChangeSecret(hash, now); - await _credentials.UpdateAsync(context.ResourceTenant, pwd, oldVersion, innerCt); + var updated = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(updated, state.SecurityVersion, innerCt); return CredentialActionResult.Success(); }); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs index 0e6f2651..549659bf 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -13,7 +13,7 @@ public interface ICredentialManagementService Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); - Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default); + Task BeginResetAsync(AccessContext context, BeginCredentialResetRequest request, CancellationToken ct = default); Task CompleteResetAsync(AccessContext context, CompleteCredentialResetRequest request, CancellationToken ct = default); diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs index fa9bc520..711f961b 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Policies; @@ -32,13 +33,15 @@ public bool AppliesTo(AccessContext context) if (!context.IsAuthenticated || context.IsSystemActor) return false; + if (context.Action.EndsWith(".anonymous")) + return false; + return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } private static readonly string[] AllowedForInactive = { - "users.status.change.", - "credentials.password.reset.", + UAuthActions.Users.ChangeStatusSelf, "login.", "reauth." }; diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs index 5e23dab6..cbe238d4 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using System.Net; namespace CodeBeam.UltimateAuth.Policies; @@ -12,5 +14,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("unauthenticated"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) + { + return !context.Action.EndsWith(".anonymous"); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs index 52e75a09..40be093d 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs @@ -23,5 +23,13 @@ public AccessDecision Decide(AccessContext context) return AccessDecision.Deny("self_or_admin_required"); } - public bool AppliesTo(AccessContext context) => !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); + public bool AppliesTo(AccessContext context) + { + if (context.Action.EndsWith(".anonymous")) + { + return false; + } + + return !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index ae6a4dd4..becf9d37 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Policies; @@ -15,5 +16,8 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("not_self"); } - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".self"); + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".self"); + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 48a26ce2..b6eea0da 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -1,7 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Users.Contracts; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs index 75b77844..0413196d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -2,11 +2,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; -using Microsoft.AspNetCore.Identity; namespace CodeBeam.UltimateAuth.Tests.Unit; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs new file mode 100644 index 00000000..431b8579 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -0,0 +1,245 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ResetPasswordTests +{ + [Fact] + public async Task Begin_reset_with_token_should_return_token() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().NotBeNull(); + result.Token!.Length.Should().BeGreaterThan(20); + result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); + } + + [Fact] + public async Task Begin_reset_with_code_should_return_numeric_code() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + result.Token.Should().NotBeNull(); + result.Token!.Should().MatchRegex("^[0-9]{6}$"); + } + + [Fact] + public async Task Begin_reset_for_unknown_user_should_not_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginCredentialResetRequest + { + Identifier = "unknown@test.com", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().BeNull(); + } + + [Fact] + public async Task Reset_password_with_valid_token_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var result = await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task Reset_password_with_same_password_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "admin" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_lock_after_max_attempts() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + for (int i = 0; i < 3; i++) + { + try + { + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin!.Token == "000000" ? "000001" : "000000", + NewSecret = "newpass123" + }); + } + catch { } + } + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_be_single_use() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "anotherpass" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_fail_if_expired() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var clock = runtime.Clock; + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + clock.Advance(TimeSpan.FromMinutes(45)); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteCredentialResetRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 9655fb39..9827a126 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -12,6 +12,7 @@ using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; @@ -26,9 +27,11 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal sealed class TestAuthRuntime where TUserId : notnull { public IServiceProvider Services { get; } + public TestClock Clock { get; } public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) { + Clock = new TestClock(); var services = new ServiceCollection(); services.AddLogging(); @@ -60,7 +63,7 @@ public TestAuthRuntime(Action? configureServer = null, Actio var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); services.AddSingleton(configuration); - + services.AddSingleton(Clock); Services = services.BuildServiceProvider(); Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs new file mode 100644 index 00000000..02256391 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestClock : IClock +{ + private DateTimeOffset _utcNow; + + public TestClock(DateTimeOffset? initial = null) + { + _utcNow = initial ?? DateTimeOffset.UtcNow; + } + + public DateTimeOffset UtcNow => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + + public void Set(DateTimeOffset time) + { + _utcNow = time; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index a129abe1..961ee06b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -7,7 +7,7 @@ using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using static CodeBeam.UltimateAuth.Server.Defaults.UAuthActions; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Tests.Unit; From 012b85f0d959a2724b9f706d4ae5bbce2e25b38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 5 Mar 2026 14:31:09 +0300 Subject: [PATCH 15/29] Added Generic InMemory Store and User Lifecycle Store Implementation --- .../Components/Pages/Home.razor | 1 + .../Components/Pages/Login.razor.cs | 1 - .../Components/Pages/Register.razor | 54 +++++++++ .../Components/Pages/Register.razor.cs | 34 ++++++ .../Login/UAuthLoginPageDiscovery.cs | 2 +- .../Abstractions/Entity/IVersionedEntity.cs | 2 +- .../Abstractions/Stores/ISoftDeleteable.cs | 8 ++ .../Abstractions/Stores/IVersionedStore.cs | 17 +++ .../Stores/InMemoryVersionedStore.cs | 92 +++++++++++++++ .../Domain/Session/UAuthSession.cs | 3 +- .../Domain/Session/UAuthSessionChain.cs | 2 +- .../Domain/Session/UAuthSessionRoot.cs | 2 +- .../Domain/Token/StoredRefreshToken.cs | 2 +- .../Domain/Role.cs | 2 +- .../Domain/PasswordCredential.cs | 2 +- .../InMemoryUserSeedContributor.cs | 13 +-- .../Stores/InMemoryUserIdentifierStore.cs | 2 +- .../Stores/InMemoryUserLifecycleStore.cs | 107 +++++------------- .../Domain/UserIdentifier.cs | 10 +- .../Domain/UserLifecycle.cs | 74 ++++++++++-- .../Domain/UserLifecycleKey.cs | 8 ++ .../Services/UserApplicationService.cs | 24 ++-- .../Stores/IUserLifecycleStore.cs | 17 +-- .../Stores/UserRuntimeStateProvider.cs | 3 +- .../Users/IdentifierConcurrencyTests.cs | 2 +- 25 files changed, 340 insertions(+), 144 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 8253b81b..d81129a8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -134,6 +134,7 @@ Last Validated At + @* TODO: Validation call should update last validated at *@ @FormatLocalTime(AuthState?.LastValidatedAt) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index 4a0cbb91..f4610520 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -85,7 +85,6 @@ private async Task ProgrammaticLogin() { Identifier = "admin", Secret = "admin", - //Device = DeviceContext.Create(deviceId, null, null, null, null, null), }; await UAuthClient.Flows.LoginAsync(request, "/home"); } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor new file mode 100644 index 00000000..f7918aaf --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,54 @@ +@page "/register" +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs new file mode 100644 index 00000000..36265299 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Users.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Register +{ + private string? _username; + private string? _password; + private string? _passwordCheck; + private string? _email; + private UAuthClientProductInfo? _productInfo; + private MudForm _form = null!; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + private async Task HandleRegisterAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + var request = new CreateUserRequest + { + + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs index 171bbabd..7e1d5999 100644 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs +++ b/src/CodeBeam.UltimateAuth.Client/Infrastructure/Login/UAuthLoginPageDiscovery.cs @@ -26,7 +26,7 @@ public static string Resolve() return _cached = "/login"; if (candidates.Count > 1) - throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Define Navigation.LoginResolver explicitly."); + throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly."); var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute; diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs index be29b0ab..893206e2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs @@ -2,5 +2,5 @@ public interface IVersionedEntity { - long Version { get; } + long Version { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs new file mode 100644 index 00000000..62b8c1f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISoftDeletable +{ + bool IsDeleted { get; } + + void MarkDeleted(DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs new file mode 100644 index 00000000..4c9c05ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +// TODO: Add IQueryStore +public interface IVersionedStore +{ + Task GetAsync(TKey key, CancellationToken ct = default); + + Task ExistsAsync(TKey key, CancellationToken ct = default); + + Task CreateAsync(TEntity entity, CancellationToken ct = default); + + Task UpdateAsync(TEntity entity, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(TKey key, DeleteMode deleteMode, DateTimeOffset now, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs new file mode 100644 index 00000000..a2f633ba --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -0,0 +1,92 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public abstract class InMemoryVersionedStore where TEntity : class, IVersionedEntity where TKey : notnull, IEquatable +{ + private readonly ConcurrentDictionary _store = new(); + + protected abstract TKey GetKey(TEntity entity); + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + _store.TryGetValue(key, out var entity); + return Task.FromResult(entity); + } + + public Task ExistsAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(_store.ContainsKey(key)); + } + + public Task CreateAsync(TEntity entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + + if (!_store.TryAdd(key, entity)) + throw new InvalidOperationException($"{typeof(TEntity).Name} already exists."); + + return Task.CompletedTask; + } + + public Task UpdateAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + + if (!_store.TryGetValue(key, out var current)) + throw new InvalidOperationException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new InvalidOperationException($"{typeof(TEntity).Name} version conflict."); + + entity.Version++; + + if (!_store.TryUpdate(key, entity, current)) + throw new InvalidOperationException($"{typeof(TEntity).Name} update conflict."); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TKey key, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(key, out var current)) + return Task.CompletedTask; + + if (mode == DeleteMode.Hard) + { + _store.TryRemove(key, out _); + return Task.CompletedTask; + }; + + var original = current; + if (current is ISoftDeletable soft) + { + if (soft.IsDeleted) + return Task.CompletedTask; + + soft.MarkDeleted(now); + current.Version++; + + if (!_store.TryUpdate(key, current, original)) + throw new UAuthConflictException("Delete conflict"); + } + + return Task.CompletedTask; + } + + protected IEnumerable Values => _store.Values; + + protected bool TryGet(TKey key, out TEntity? entity) => _store.TryGetValue(key, out entity); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 16d63263..806a9c5b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -3,6 +3,7 @@ namespace CodeBeam.UltimateAuth.Core.Domain; +// TODO: Add ISoftDeleteable public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } @@ -16,7 +17,7 @@ public sealed class UAuthSession : IVersionedEntity public long SecurityVersionAtCreation { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } - public long Version { get; } + public long Version { get; set; } private UAuthSession( AuthSessionId sessionId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 13f54b7a..4e195a0c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -22,7 +22,7 @@ public sealed class UAuthSessionChain : IVersionedEntity public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } - public long Version { get; } + public long Version { get; set; } private UAuthSessionChain( SessionChainId chainId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 483d06a8..0ae69287 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -16,7 +16,7 @@ public sealed class UAuthSessionRoot : IVersionedEntity public DateTimeOffset? RevokedAt { get; } public long SecurityVersion { get; } - public long Version { get; } + public long Version { get; set; } private UAuthSessionRoot( SessionRootId rootId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs index 306ef666..bd21a678 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs @@ -25,7 +25,7 @@ public sealed record StoredRefreshToken : IVersionedEntity public string? ReplacedByTokenHash { get; init; } - public long Version { get; } + public long Version { get; set; } [NotMapped] public bool IsRevoked => RevokedAt.HasValue; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs index 60c76771..15597431 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs @@ -6,5 +6,5 @@ public sealed class Role : IVersionedEntity public required string Name { get; init; } public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); - public long Version { get; init; } + public long Version { get; set; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 1d890a8d..636624e9 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -20,7 +20,7 @@ public sealed class PasswordCredential : ISecretCredential, ICredentialDescripto public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; init; } - public long Version { get; private set; } + public long Version { get; set; } public bool IsRevoked => Security.RevokedAt is not null; public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index 6666c55d..afd1e762 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -43,17 +43,12 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displayName, string username, string email, string phone, CancellationToken ct) { - if (await _lifecycle.ExistsAsync(tenant, userKey, ct)) + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + if (await _lifecycle.ExistsAsync(userLifecycleKey, ct)) return; - await _lifecycle.CreateAsync(tenant, - new UserLifecycle - { - Tenant = tenant, - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = _clock.UtcNow - }, ct); + await _lifecycle.CreateAsync( + UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); await _profiles.CreateAsync(tenant, new UserProfile diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index b6e375f4..535f6d7a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -261,7 +261,7 @@ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMod else { var expected = identifier.Version; - identifier.SoftDelete(deletedAt); + identifier.MarkDeleted(deletedAt); await SaveAsync(identifier, expected, ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 82ae2629..0e338363 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,30 +1,14 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore +public sealed class InMemoryUserLifecycleStore : InMemoryVersionedStore, IUserLifecycleStore { - private readonly Dictionary<(TenantKey, UserKey), UserLifecycle> _store = new(); - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var entity) && !entity.IsDeleted); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity)) - return Task.FromResult(null); - - if (entity.IsDeleted) - return Task.FromResult(null); - - return Task.FromResult(entity); - } + protected override UserLifecycleKey GetKey(UserLifecycle entity) + => new(entity.Tenant, entity.UserKey); public Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default) { @@ -32,7 +16,7 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc var normalized = query.Normalize(); - var baseQuery = _store.Values + var baseQuery = Values .Where(x => x?.UserKey != null) .Where(x => x.Tenant == tenant); @@ -44,6 +28,11 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc baseQuery = query.SortBy switch { + nameof(UserLifecycle.Id) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Id) + : baseQuery.OrderBy(x => x.Id), + nameof(UserLifecycle.CreatedAt) => query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) @@ -54,6 +43,21 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status), + nameof(UserLifecycle.Tenant) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Tenant.Value) + : baseQuery.OrderBy(x => x.Tenant.Value), + + nameof(UserLifecycle.UserKey) => + query.Descending + ? baseQuery.OrderByDescending(x => x.UserKey.Value) + : baseQuery.OrderBy(x => x.UserKey.Value), + + nameof(UserLifecycle.DeletedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DeletedAt) + : baseQuery.OrderBy(x => x.DeletedAt), + _ => baseQuery.OrderBy(x => x.CreatedAt) }; @@ -62,63 +66,4 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); } - - public Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default) - { - var key = (tenant, lifecycle.UserKey); - - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserLifecycle already exists."); - - _store[key] = lifecycle; - return Task.CompletedTask; - } - - public Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - entity.Status = newStatus; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var entity) || entity.IsDeleted) - throw new InvalidOperationException("UserLifecycle not found."); - - if (entity.SecurityStamp == newSecurityStamp) - return Task.CompletedTask; - - entity.SecurityStamp = newSecurityStamp; - entity.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var entity)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _store.Remove(key); - return Task.CompletedTask; - } - - // Soft delete (idempotent) - if (entity.IsDeleted) - return Task.CompletedTask; - - entity.IsDeleted = true; - entity.DeletedAt = deletedAt; - - return Task.CompletedTask; - } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index ec9951a1..284db6fc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -5,7 +5,7 @@ using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record UserIdentifier : IVersionedEntity +public sealed record UserIdentifier : IVersionedEntity, ISoftDeletable { public Guid Id { get; set; } public TenantKey Tenant { get; set; } @@ -19,8 +19,6 @@ public sealed record UserIdentifier : IVersionedEntity public bool IsPrimary { get; set; } public bool IsVerified { get; set; } - public bool IsDeleted { get; set; } - public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? VerifiedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } @@ -29,6 +27,8 @@ public sealed record UserIdentifier : IVersionedEntity public long Version { get; set; } + public bool IsDeleted => DeletedAt is not null; + public UserIdentifier Cloned() { return new UserIdentifier @@ -41,7 +41,6 @@ public UserIdentifier Cloned() NormalizedValue = NormalizedValue, IsPrimary = IsPrimary, IsVerified = IsVerified, - IsDeleted = IsDeleted, CreatedAt = CreatedAt, UpdatedAt = UpdatedAt, VerifiedAt = VerifiedAt, @@ -111,12 +110,11 @@ public void UnsetPrimary(DateTimeOffset at) Version++; } - public void SoftDelete(DateTimeOffset at) + public void MarkDeleted(DateTimeOffset at) { if (IsDeleted) throw new UAuthIdentifierConflictException("identifier_already_deleted"); - IsDeleted = true; DeletedAt = at; IsPrimary = false; UpdatedAt = at; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 670922b7..e3d17778 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -5,20 +5,76 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record class UserLifecycle : IVersionedEntity +public sealed class UserLifecycle : IVersionedEntity { - public TenantKey Tenant { get; set; } + private UserLifecycle() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } = default!; + public UserKey UserKey { get; private set; } = default!; - public UserStatus Status { get; set; } = UserStatus.Active; - public Guid SecurityStamp { get; set; } + public UserStatus Status { get; private set; } - public bool IsDeleted { get; set; } + public long SecurityVersion { get; private set; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public bool IsActive => !IsDeleted && Status == UserStatus.Active; + + public static UserLifecycle Create(TenantKey tenant, UserKey userKey, DateTimeOffset now, Guid? id = null) + { + return new UserLifecycle + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = now, + SecurityVersion = 0, + Version = 0 + }; + } + + public UserLifecycle MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + SecurityVersion++; + + return this; + } + + public UserLifecycle Activate(DateTimeOffset now) + { + if (Status == UserStatus.Active) + return this; + + Status = UserStatus.Active; + UpdatedAt = now; + return this; + } + + public UserLifecycle ChangeStatus(DateTimeOffset now, UserStatus newStatus) + { + if (Status == newStatus) + return this; + + Status = newStatus; + UpdatedAt = now; + return this; + } + + public UserLifecycle IncrementSecurityVersion() + { + SecurityVersion++; + return this; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs new file mode 100644 index 00000000..8e1ad17b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserLifecycleKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index acc38777..b828e45d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -55,18 +55,12 @@ public async Task CreateUserAsync(AccessContext context, Creat return UserCreateResult.Failed("primary_identifier_type_required"); } - await _lifecycleStore.CreateAsync(context.ResourceTenant, - new UserLifecycle - { - UserKey = userKey, - Status = UserStatus.Active, - CreatedAt = now - }, - innerCt); + await _lifecycleStore.CreateAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); await _profileStore.CreateAsync(context.ResourceTenant, new UserProfile { + Tenant = context.ResourceTenant, UserKey = userKey, FirstName = request.FirstName, LastName = request.LastName, @@ -87,6 +81,7 @@ await _profileStore.CreateAsync(context.ResourceTenant, await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { + Tenant = context.ResourceTenant, UserKey = userKey, Type = request.PrimaryIdentifierType.Value, Value = request.PrimaryIdentifierValue, @@ -121,7 +116,9 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C }; var targetUserKey = context.GetTargetUserKey(); - var current = await _lifecycleStore.GetAsync(context.ResourceTenant, targetUserKey, innerCt); + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + var current = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + var now = DateTimeOffset.UtcNow; if (current is null) throw new InvalidOperationException("user_not_found"); @@ -134,8 +131,8 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) throw new InvalidOperationException("admin_cannot_set_self_status"); } - - await _lifecycleStore.ChangeStatusAsync(context.ResourceTenant, targetUserKey, newStatus, _clock.UtcNow, innerCt); + var newEntity = current.ChangeStatus(now, newStatus); + await _lifecycleStore.UpdateAsync(newEntity, current.Version, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -147,8 +144,9 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque { var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); - await _lifecycleStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + await _lifecycleStore.DeleteAsync(userLifecycleKey, request.Mode, now, innerCt); await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); @@ -520,7 +518,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde } else { - identifier.SoftDelete(_clock.UtcNow); + identifier.MarkDeleted(_clock.UtcNow); await _identifierStore.SaveAsync(identifier, expectedVersion, innerCt); } }); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 38b0c42f..c930be38 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,23 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserLifecycleStore +public interface IUserLifecycleStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserLifecycle lifecycle, CancellationToken ct = default); - - Task ChangeStatusAsync(TenantKey tenant, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task ChangeSecurityStampAsync(TenantKey tenant, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index b864f6e8..a21204d3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -16,7 +16,8 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var lifecycle = await _lifecycleStore.GetAsync(tenant, userKey, ct); + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, ct); if (lifecycle is null) return null; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 320d7a08..7a0b7a9c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -65,7 +65,7 @@ public async Task Delete_should_throw_when_version_conflicts() var copy2 = await store.GetByIdAsync(id); var expected1 = copy1!.Version; - copy1.SoftDelete(now); + copy1.MarkDeleted(now); await store.SaveAsync(copy1, expected1); await Assert.ThrowsAsync(async () => From 959f1363ab31676aad2db9b9fb944d1462ee8048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 6 Mar 2026 02:30:56 +0300 Subject: [PATCH 16/29] Completed User Create --- .../Components/Pages/Login.razor | 1 + .../Components/Pages/Register.razor | 2 +- .../Components/Pages/Register.razor.cs | 15 ++- .../Program.cs | 2 +- .../Contracts/Common/UAuthValidationError.cs | 5 + .../Defaults/UAuthActions.cs | 3 +- .../HttpContext/HttpContextJsonExtensions.cs | 43 +++++++ .../HttpContextReturnUrlExtensions.cs | 0 .../HttpContextSessionExtensions.cs | 0 .../HttpContextTenantExtensions.cs | 0 .../HttpContextUserExtensions.cs | 0 .../Extensions/HttpContextJsonExtensions.cs | 25 ---- .../Extensions/ServiceCollectionExtensions.cs | 3 + .../Validator/IIdentifierValidator.cs | 9 ++ .../Validator/IUserCreateValidator.cs | 9 ++ .../Validator/IdentifierValidator.cs | 103 ++++++++++++++++ .../Validator/UserCreateValidator.cs | 64 ++++++++++ ...erOptions.cs => UAuthIdentifierOptions.cs} | 4 +- .../UAuthIdentifierValidationOptions.cs | 59 +++++++++ .../Options/UAuthServerOptions.cs | 7 +- ...uthServerUserIdentifierOptionsValidator.cs | 2 +- .../Requests/CreateUserRequest.cs | 11 +- .../Responses/IdentifierValidationResult.cs | 22 ++++ .../Responses/UserCreateValidatorResult.cs | 22 ++++ .../Domain/UserIdentifier.cs | 16 +++ .../Endpoints/UserEndpointHandler.cs | 4 +- .../Services/UserApplicationService.cs | 116 +++++++++++++++--- .../Server/ServerOptionsValidatorTests.cs | 10 +- .../UserIdentifierApplicationServiceTests.cs | 4 +- 29 files changed, 495 insertions(+), 66 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextReturnUrlExtensions.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextSessionExtensions.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextTenantExtensions.cs (100%) rename src/CodeBeam.UltimateAuth.Server/Extensions/{ => HttpContext}/HttpContextUserExtensions.cs (100%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs rename src/CodeBeam.UltimateAuth.Server/Options/{UAuthUserIdentifierOptions.cs => UAuthIdentifierOptions.cs} (91%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index 658f6385..5472eb31 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -117,6 +117,7 @@ Forgot Password + Don't have an account? SignUp diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor index f7918aaf..e32cc79c 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -44,7 +44,7 @@ - Sign Up + Sign Up diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs index 36265299..82645070 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs @@ -27,8 +27,19 @@ private async Task HandleRegisterAsync() var request = new CreateUserRequest { - + UserName = _username, + Password = _password, + Email = _email, }; - } + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("User created succesfully.", Severity.Success); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to create user.", Severity.Error); + } + } } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index ff22916c..1c56aaf5 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -48,7 +48,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.UserIdentifiers.AllowMultipleUsernames = true; + o.Identifiers.AllowMultipleUsernames = true; }) .AddUltimateAuthUsersInMemory() .AddUltimateAuthUsersReference() diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs new file mode 100644 index 00000000..08a742da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs @@ -0,0 +1,5 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record UAuthValidationError( + string Code, + string? Field = null); \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 2beb517c..3be8ffa5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -19,7 +19,8 @@ public static class Sessions public static class Users { - public const string Create = "users.create"; + public const string CreateAnonymous = "users.create.anonymous"; + public const string CreateAdmin = "users.create.admin"; public const string DeleteAdmin = "users.delete.admin"; public const string ChangeStatusSelf = "users.status.change.self"; public const string ChangeStatusAdmin = "users.status.change.admin"; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs new file mode 100644 index 00000000..262f8aee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextJsonExtensions +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) + { + var request = ctx.Request; + + if (!request.HasJsonContentType()) + throw new InvalidOperationException("Request content type must be application/json."); + + if (request.Body == null || request.ContentLength == 0) + throw new InvalidOperationException("Request body is empty."); + + request.EnableBuffering(); + + request.Body.Position = 0; + + try + { + var result = await JsonSerializer.DeserializeAsync(request.Body, JsonOptions, ct); + + request.Body.Position = 0; + + if (result == null) + throw new InvalidOperationException("Request body could not be deserialized."); + + return result; + } + catch (JsonException) + { + throw new InvalidOperationException("Invalid JSON"); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextReturnUrlExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextTenantExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextUserExtensions.cs rename to src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs deleted file mode 100644 index c429e7c5..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Extensions; - -public static class HttpContextJsonExtensions -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) - { - if (!ctx.Request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); - - if (ctx.Request.Body is null) - throw new InvalidOperationException("Request body is empty."); - - var result = await JsonSerializer.DeserializeAsync(ctx.Request.Body, JsonOptions, ct); - - if (result is null) - throw new InvalidOperationException("Request body could not be deserialized."); - - return result; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 3240f676..7bb29b77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using CodeBeam.UltimateAuth.Users; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -193,6 +194,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); @@ -299,6 +301,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services) { services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs new file mode 100644 index 00000000..21243698 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierValidator +{ + Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs new file mode 100644 index 00000000..8157aeee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserCreateValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserCreateValidator +{ + Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs new file mode 100644 index 00000000..3fb399b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierValidator : IIdentifierValidator +{ + private readonly UAuthIdentifierValidationOptions _options; + + public IdentifierValidator(IOptions options) + { + _options = options.Value.IdentifierValidation; + } + + public Task ValidateAsync(AccessContext context, UserIdentifierDto identifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var errors = new List(); + + if (string.IsNullOrWhiteSpace(identifier.Value)) + { + errors.Add(new("identifier_empty")); + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + identifier.Value = identifier.Value.Trim(); + + switch (identifier.Type) + { + case UserIdentifierType.Username: + ValidateUsername(identifier.Value, errors); + break; + + case UserIdentifierType.Email: + ValidateEmail(identifier.Value, errors); + break; + + case UserIdentifierType.Phone: + ValidatePhone(identifier.Value, errors); + break; + } + + if (errors.Count == 0) + return Task.FromResult(IdentifierValidationResult.Success()); + + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + private void ValidateUsername(string username, List errors) + { + var rule = _options.UserName; + + if (!rule.Enabled) + return; + + if (username.Length < rule.MinLength) + errors.Add(new("username_too_short", "username")); + + if (username.Length > rule.MaxLength) + errors.Add(new("username_too_long", "username")); + + if (!string.IsNullOrWhiteSpace(rule.AllowedRegex)) + { + if (!Regex.IsMatch(username, rule.AllowedRegex)) + errors.Add(new("username_invalid_format", "username")); + } + } + + private void ValidateEmail(string email, List errors) + { + var rule = _options.Email; + + if (!rule.Enabled) + return; + + if (email.Length < rule.MinLength) + errors.Add(new("email_too_short", "email")); + + if (email.Length > rule.MaxLength) + errors.Add(new("email_too_long", "email")); + + if (!email.Contains('@')) + errors.Add(new("email_invalid_format", "email")); + } + + private void ValidatePhone(string phone, List errors) + { + var rule = _options.Phone; + + if (!rule.Enabled) + return; + + if (phone.Length < rule.MinLength) + errors.Add(new("phone_too_short", "phone")); + + if (phone.Length > rule.MaxLength) + errors.Add(new("phone_too_long", "phone")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs new file mode 100644 index 00000000..b08b2150 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -0,0 +1,64 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UserCreateValidator : IUserCreateValidator +{ + private readonly IIdentifierValidator _identifierValidator; + + public UserCreateValidator(IIdentifierValidator identifierValidator) + { + _identifierValidator = identifierValidator; + } + + public async Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(request.UserName) && + string.IsNullOrWhiteSpace(request.Email) && + string.IsNullOrWhiteSpace(request.Phone)) + { + errors.Add(new("identifier_required")); + } + + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Email, + Value = request.Email + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierDto() + { + Type = UserIdentifierType.Phone, + Value = request.Phone + }, ct); + + errors.AddRange(r.Errors); + } + + if (errors.Count == 0) + return UserCreateValidatorResult.Success(); + + return UserCreateValidatorResult.Failed(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs similarity index 91% rename from src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs rename to src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs index fd995ac8..1d7c9c6a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; -public sealed class UAuthUserIdentifierOptions +public sealed class UAuthIdentifierOptions { public bool AllowUsernameChange { get; set; } = true; public bool AllowMultipleUsernames { get; set; } = false; @@ -14,7 +14,7 @@ public sealed class UAuthUserIdentifierOptions public bool AllowAdminOverride { get; set; } = true; public bool AllowUserOverride { get; set; } = true; - internal UAuthUserIdentifierOptions Clone() => new() + internal UAuthIdentifierOptions Clone() => new() { AllowUsernameChange = AllowUsernameChange, AllowMultipleUsernames = AllowMultipleUsernames, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs new file mode 100644 index 00000000..612c518a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs @@ -0,0 +1,59 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierValidationOptions +{ + public UsernameIdentifierRule UserName { get; set; } = new(); + public EmailIdentifierRule Email { get; set; } = new(); + public PhoneIdentifierRule Phone { get; set; } = new(); + + internal UAuthIdentifierValidationOptions Clone() => new() + { + UserName = UserName.Clone(), + Email = Email.Clone(), + Phone = Phone.Clone() + }; +} + +public sealed class UsernameIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 64; + public string? AllowedRegex { get; set; } = "^[a-zA-Z0-9._-]+$"; + + internal UsernameIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + AllowedRegex = AllowedRegex, + }; +} + +public sealed class EmailIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 256; + + internal EmailIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} + +public sealed class PhoneIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 24; + + internal PhoneIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 4d4af86b..34f41c83 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -89,7 +89,9 @@ public sealed class UAuthServerOptions /// public UAuthServerEndpointOptions Endpoints { get; set; } = new(); - public UAuthUserIdentifierOptions UserIdentifiers { get; set; } = new(); + public UAuthIdentifierOptions Identifiers { get; set; } = new(); + + public UAuthIdentifierValidationOptions IdentifierValidation { get; set; } = new(); public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); @@ -143,7 +145,8 @@ internal UAuthServerOptions Clone() ResetCredential = ResetCredential.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), - UserIdentifiers = UserIdentifiers.Clone(), + Identifiers = Identifiers.Clone(), + IdentifierValidation = IdentifierValidation.Clone(), LoginIdentifiers = LoginIdentifiers.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs index 82af7684..3e8ce27b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -6,7 +6,7 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { - if (!options.UserIdentifiers.AllowAdminOverride && !options.UserIdentifiers.AllowUserOverride) + if (!options.Identifiers.AllowAdminOverride && !options.Identifiers.AllowUserOverride) { return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + "At least one actor must be able to manage user identifiers."); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs index ff720ce8..e078eb69 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -2,6 +2,13 @@ public sealed record CreateUserRequest { + public string? UserName { get; init; } + public bool UserNameVerified { get; init; } + public string? Email { get; init; } + public bool EmailVerified { get; init; } + public string? Phone { get; init; } + public bool PhoneVerified { get; init; } + public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } @@ -15,8 +22,4 @@ public sealed record CreateUserRequest public string? TimeZone { get; init; } public string? Culture { get; init; } public IReadOnlyDictionary? Metadata { get; init; } - - public UserIdentifierType? PrimaryIdentifierType { get; init; } - public string? PrimaryIdentifierValue { get; init; } - public bool PrimaryIdentifierVerified { get; init; } = false; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs new file mode 100644 index 00000000..f79173ef --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierValidationResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private IdentifierValidationResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static IdentifierValidationResult Success() + => new(true, Array.Empty()); + + public static IdentifierValidationResult Failed(IEnumerable errors) + => new(false, errors.ToList()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs new file mode 100644 index 00000000..90f48c27 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserCreateValidatorResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private UserCreateValidatorResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static UserCreateValidatorResult Success() + => new(true, Array.Empty()); + + public static UserCreateValidatorResult Failed(IEnumerable errors) + => new(false, errors.ToList().AsReadOnly()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 284db6fc..2d8d41ca 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -121,4 +121,20 @@ public void MarkDeleted(DateTimeOffset at) Version++; } + + public UserIdentifierDto ToDto() + { + return new UserIdentifierDto() + { + Id = Id, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + CreatedAt = CreatedAt, + IsPrimary = IsPrimary, + IsVerified = IsVerified, + VerifiedAt = VerifiedAt, + Version = Version + }; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index b6eea0da..fce89093 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -24,14 +24,12 @@ public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFact public async Task CreateAsync(HttpContext ctx) { var flow = _authFlow.Current; - if (!flow.IsAuthenticated) - return Results.Unauthorized(); var request = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, - action: UAuthActions.Users.Create, + action: UAuthActions.Users.CreateAnonymous, resource: "users"); var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index b828e45d..4ef8b376 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -3,6 +3,9 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Contracts; @@ -16,8 +19,12 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserLifecycleStore _lifecycleStore; private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; + private readonly ICredentialStore _credentialStore; + private readonly IUserCreateValidator _userCreateValidator; + private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly IUAuthPasswordHasher _passwordHasher; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -26,8 +33,12 @@ public UserApplicationService( IUserLifecycleStore lifecycleStore, IUserProfileStore profileStore, IUserIdentifierStore identifierStore, + ICredentialStore credentialStore, + IUserCreateValidator userCreateValidator, + IIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, + IUAuthPasswordHasher passwordHasher, IOptions options, IClock clock) { @@ -35,8 +46,12 @@ public UserApplicationService( _lifecycleStore = lifecycleStore; _profileStore = profileStore; _identifierStore = identifierStore; + _credentialStore = credentialStore; + _userCreateValidator = userCreateValidator; + _identifierValidator = identifierValidator; _integrations = integrations; _identifierNormalizer = identifierNormalizer; + _passwordHasher = passwordHasher; _options = options.Value; _clock = clock; } @@ -47,16 +62,32 @@ public async Task CreateUserAsync(AccessContext context, Creat { var command = new CreateUserCommand(async innerCt => { + var validationResult = await _userCreateValidator.ValidateAsync(context, request, ct); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + var now = _clock.UtcNow; var userKey = UserKey.New(); - if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is null) + if (string.IsNullOrWhiteSpace(request.UserName) && string.IsNullOrWhiteSpace(request.Email) && string.IsNullOrWhiteSpace(request.Phone)) { - return UserCreateResult.Failed("primary_identifier_type_required"); + throw new UAuthValidationException("identifier_required"); } await _lifecycleStore.CreateAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); + if (!string.IsNullOrWhiteSpace(request.Password)) + { + var hash = _passwordHasher.Hash(request.Password); + + await _credentialStore.AddAsync( + context.ResourceTenant, + PasswordCredential.Create(null, context.ResourceTenant, userKey, hash, CredentialSecurityState.Active(), new CredentialMetadata(), now), + innerCt); + } + await _profileStore.CreateAsync(context.ResourceTenant, new UserProfile { @@ -64,7 +95,7 @@ await _profileStore.CreateAsync(context.ResourceTenant, UserKey = userKey, FirstName = request.FirstName, LastName = request.LastName, - DisplayName = request.DisplayName, + DisplayName = request.DisplayName ?? request.UserName ?? request.Email, BirthDate = request.BirthDate, Gender = request.Gender, Bio = request.Bio, @@ -76,19 +107,56 @@ await _profileStore.CreateAsync(context.ResourceTenant, }, innerCt); - if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + await _identifierStore.CreateAsync(context.ResourceTenant, + new UserIdentifier + { + Tenant = context.ResourceTenant, + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = request.UserName, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, + IsPrimary = true, + IsVerified = request.UserNameVerified, + CreatedAt = now, + VerifiedAt = request.UserNameVerified ? now : null + }, + innerCt); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) { await _identifierStore.CreateAsync(context.ResourceTenant, new UserIdentifier { Tenant = context.ResourceTenant, UserKey = userKey, - Type = request.PrimaryIdentifierType.Value, - Value = request.PrimaryIdentifierValue, + Type = UserIdentifierType.Email, + Value = request.Email, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, IsPrimary = true, - IsVerified = request.PrimaryIdentifierVerified, + IsVerified = request.EmailVerified, CreatedAt = now, - VerifiedAt = request.PrimaryIdentifierVerified ? now : null + VerifiedAt = request.EmailVerified ? now : null + }, + innerCt); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + await _identifierStore.CreateAsync(context.ResourceTenant, + new UserIdentifier + { + Tenant = context.ResourceTenant, + UserKey = userKey, + Type = UserIdentifierType.Phone, + Value = request.Phone, + NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + IsPrimary = true, + IsVerified = request.PhoneVerified, + CreatedAt = now, + VerifiedAt = request.PhoneVerified ? now : null }, innerCt); } @@ -269,6 +337,13 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie { var command = new AddUserIdentifierCommand(async innerCt => { + var validationDto = new UserIdentifierDto() { Type = request.Type, Value = request.Value }; + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, ct); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + EnsureOverrideAllowed(context); var userKey = context.GetTargetUserKey(); @@ -341,11 +416,18 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - if (identifier.Type == UserIdentifierType.Username && !_options.UserIdentifiers.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } + var validationDto = identifier.ToDto(); + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, ct); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue); if (!normalized.IsValid) throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); @@ -497,7 +579,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (identifier.IsPrimary) throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); - if (_options.UserIdentifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) + if (_options.Identifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) { var activeUsernames = identifiers .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) @@ -563,24 +645,24 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL if (!hasSameType) return; - if (type == UserIdentifierType.Username && !_options.UserIdentifiers.AllowMultipleUsernames) + if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) throw new InvalidOperationException("multiple_usernames_not_allowed"); - if (type == UserIdentifierType.Email && !_options.UserIdentifiers.AllowMultipleEmail) + if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) throw new InvalidOperationException("multiple_emails_not_allowed"); - if (type == UserIdentifierType.Phone && !_options.UserIdentifiers.AllowMultiplePhone) + if (type == UserIdentifierType.Phone && !_options.Identifiers.AllowMultiplePhone) throw new InvalidOperationException("multiple_phones_not_allowed"); } private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { - if (type == UserIdentifierType.Email && _options.UserIdentifiers.RequireEmailVerification && !isVerified) + if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) { throw new InvalidOperationException("email_verification_required"); } - if (type == UserIdentifierType.Phone && _options.UserIdentifiers.RequirePhoneVerification && !isVerified) + if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) { throw new InvalidOperationException("phone_verification_required"); } @@ -588,10 +670,10 @@ private void EnsureVerificationRequirements(UserIdentifierType type, bool isVeri private void EnsureOverrideAllowed(AccessContext context) { - if (context.IsSelfAction && !_options.UserIdentifiers.AllowUserOverride) + if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) throw new InvalidOperationException("user_override_not_allowed"); - if (!context.IsSelfAction && !_options.UserIdentifiers.AllowAdminOverride) + if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) throw new InvalidOperationException("admin_override_not_allowed"); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index f25dfb04..90b20a6f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -243,8 +243,8 @@ public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = false; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = false; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); @@ -268,14 +268,14 @@ public void UserIdentifiers_at_least_one_override_enabled_should_pass() services.AddOptions() .Configure(o => { - o.UserIdentifiers.AllowAdminOverride = true; - o.UserIdentifiers.AllowUserOverride = false; + o.Identifiers.AllowAdminOverride = true; + o.Identifiers.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; - options.UserIdentifiers.AllowAdminOverride.Should().BeTrue(); + options.Identifiers.AllowAdminOverride.Should().BeTrue(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 961ee06b..277d6922 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -242,7 +242,7 @@ public async Task Username_should_respect_case_policy() { var runtime = new TestAuthRuntime(configureServer: o => { - o.UserIdentifiers.AllowMultipleUsernames = true; + o.Identifiers.AllowMultipleUsernames = true; }); var service = runtime.GetUserApplicationService(); @@ -267,7 +267,7 @@ public async Task Username_should_be_case_insensitive_when_configured() var runtime = new TestAuthRuntime(configureServer: o => { o.LoginIdentifiers.Normalization.UsernameCase = CaseHandling.ToLower; - o.UserIdentifiers.AllowMultipleUsernames = true; + o.Identifiers.AllowMultipleUsernames = true; }); var service = runtime.GetUserApplicationService(); From 031a9bc856c62a4fb43df86dcc273214ab6f2209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 8 Mar 2026 04:30:53 +0300 Subject: [PATCH 17/29] Enhanced Authorization (RBAC) --- .../Abstractions/Entity/IEntitySnapshot.cs | 6 + .../Services/IUAuthSessionManager.cs | 31 -- .../Abstractions/Stores/ISoftDeleteable.cs | 5 +- .../Abstractions/Stores/IVersionedStore.cs | 6 +- .../Stores/InMemoryVersionedStore.cs | 73 ++-- .../Contracts/Authority/AccessContext.cs | 21 ++ .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Orchestrator/UAuthAccessOrchestrator.cs | 20 +- .../Services/UAuthSessionManager.cs | 49 --- .../Domain/Permission.cs | 16 + .../Dtos/RoleQuery.cs | 9 + .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../InMemoryAuthorizationSeedContributor.cs | 21 +- .../Stores/InMemoryRoleStore.cs | 128 +++++++ .../Stores/InMemoryUserRoleStore.cs | 25 +- ...ltimateAuth.Authorization.Reference.csproj | 4 + .../Domain/Role.cs | 10 - .../Domain/UserRole.cs | 15 + .../Endpoints/AuthorizationEndpointHandler.cs | 18 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Infrastructure/RolePermissionResolver.cs | 41 ++- .../Infrastructure/UserPermissionStore.cs | 12 +- .../Services/AuthorizationService.cs | 31 +- .../Services/RoleService.cs | 109 ++++++ .../Services/UserRoleService.cs | 53 +-- .../Abstractions/IRolePermissionResolver.cs | 5 +- .../Abstractions/IRoleService.cs | 18 + .../Abstractions/IRoleStore.cs | 13 + .../Abstractions/IUserPermissionStore.cs | 3 +- .../Abstractions/IUserRoleService.cs | 4 +- .../Abstractions/IUserRoleStore.cs | 9 +- .../AuthorizationClaimsProvider.cs | 9 +- ...CodeBeam.UltimateAuth.Authorization.csproj | 1 + .../Domain/Permission.cs | 6 - .../Domain/Role.cs | 134 ++++++++ .../Domain/RoleId.cs | 10 + .../Domain/RoleKey.cs | 7 + .../PermissionAccessPolicy.cs | 46 +-- .../Domain/CredentialKey.cs | 7 + .../Dtos/CredentialSecurityState.cs | 3 + .../InMemoryCredentialSeedContributor.cs | 8 +- .../InMemoryCredentialStore.cs | 98 ++---- .../Domain/PasswordCredential.cs | 130 ++++--- .../PasswordUserLifecycleIntegration.cs | 4 +- .../Services/CredentialManagementService.cs | 14 +- .../Abstractions/ICredential.cs | 4 +- .../Abstractions/ICredentialStore.cs | 11 +- .../CodeBeam.UltimateAuth.Policies.csproj | 1 + .../Defaults/DefaultPolicySet.cs | 6 +- .../Policies/MustHavePermissionPolicy.cs | 29 ++ .../Policies/RequireSelfPolicy.cs | 1 - .../InMemoryUserSeedContributor.cs | 91 ++--- .../Stores/InMemoryUserIdentifierStore.cs | 320 +++++++----------- .../Stores/InMemoryUserLifecycleStore.cs | 3 +- .../Stores/InMemoryUserProfileStore.cs | 112 ++---- .../Contracts/UserProfileUpdate.cs | 17 - .../Domain/UserIdentifier.cs | 76 +++-- .../Domain/UserLifecycle.cs | 18 +- .../Domain/UserProfile.cs | 164 ++++++++- .../Domain/UserProfileKey.cs | 8 + .../UserProfileSnapshotProvider.cs | 2 +- .../Mapping/UserProfileMapper.cs | 15 - .../Services/UserApplicationService.cs | 196 ++++++----- .../Stores/IUserIdentifierStore.cs | 10 +- .../Stores/IUserProfileStore.cs | 16 +- 65 files changed, 1407 insertions(+), 933 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/UserRole.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleId.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs new file mode 100644 index 00000000..12995e3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IEntitySnapshot +{ + T Snapshot(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs deleted file mode 100644 index 996b5a64..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionManager.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Abstractions; - -/// -/// Application-level session command API. -/// Represents explicit intent to mutate session state. -/// All operations are authorization- and policy-aware. -/// -public interface IUAuthSessionManager -{ - /// - /// Revokes a single session (logout current device). - /// - Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); - - /// - /// Revokes all sessions in a specific chain (logout a device). - /// - Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default); - - /// - /// Revokes all session chains for the current user (logout all devices). - /// - Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId = null, CancellationToken ct = default); - - /// - /// Hard revoke: revokes the entire session root (admin / security action). - /// - Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default); -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs index 62b8c1f6..ecbf8540 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs @@ -1,8 +1,9 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; -public interface ISoftDeletable +public interface ISoftDeletable { bool IsDeleted { get; } + DateTimeOffset? DeletedAt { get; } - void MarkDeleted(DateTimeOffset now); + T MarkDeleted(DateTimeOffset now); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs index 4c9c05ef..f7a02f0f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs @@ -9,9 +9,9 @@ public interface IVersionedStore Task ExistsAsync(TKey key, CancellationToken ct = default); - Task CreateAsync(TEntity entity, CancellationToken ct = default); + Task AddAsync(TEntity entity, CancellationToken ct = default); - Task UpdateAsync(TEntity entity, long expectedVersion, CancellationToken ct = default); + Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default); - Task DeleteAsync(TKey key, DeleteMode deleteMode, DateTimeOffset now, CancellationToken ct = default); + Task DeleteAsync(TKey key, long expectedVersion, DeleteMode deleteMode, DateTimeOffset now, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs index a2f633ba..efdaf506 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -4,18 +4,26 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; -public abstract class InMemoryVersionedStore where TEntity : class, IVersionedEntity where TKey : notnull, IEquatable +public abstract class InMemoryVersionedStore : IVersionedStore + where TEntity : class, IVersionedEntity, IEntitySnapshot + where TKey : notnull, IEquatable { private readonly ConcurrentDictionary _store = new(); protected abstract TKey GetKey(TEntity entity); + protected virtual TEntity Snapshot(TEntity entity) => entity.Snapshot(); + protected virtual void BeforeAdd(TEntity entity) { } + protected virtual void BeforeSave(TEntity entity, TEntity current, long expectedVersion) { } + protected virtual void BeforeDelete(TEntity current, long expectedVersion, DeleteMode mode, DateTimeOffset now) { } public Task GetAsync(TKey key, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _store.TryGetValue(key, out var entity); - return Task.FromResult(entity); + if (!_store.TryGetValue(key, out var entity)) + return Task.FromResult(null); + + return Task.FromResult(Snapshot(entity)); } public Task ExistsAsync(TKey key, CancellationToken ct = default) @@ -25,68 +33,79 @@ public Task ExistsAsync(TKey key, CancellationToken ct = default) return Task.FromResult(_store.ContainsKey(key)); } - public Task CreateAsync(TEntity entity, CancellationToken ct = default) + public Task AddAsync(TEntity entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var key = GetKey(entity); + var snapshot = Snapshot(entity); + + BeforeAdd(snapshot); - if (!_store.TryAdd(key, entity)) - throw new InvalidOperationException($"{typeof(TEntity).Name} already exists."); + if (!_store.TryAdd(key, snapshot)) + throw new UAuthConflictException($"{typeof(TEntity).Name} already exists."); return Task.CompletedTask; } - public Task UpdateAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) + public Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var key = GetKey(entity); if (!_store.TryGetValue(key, out var current)) - throw new InvalidOperationException($"{typeof(TEntity).Name} not found."); + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); if (current.Version != expectedVersion) - throw new InvalidOperationException($"{typeof(TEntity).Name} version conflict."); + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + var next = Snapshot(entity); + next.Version = expectedVersion + 1; - entity.Version++; + BeforeSave(next, current, expectedVersion); - if (!_store.TryUpdate(key, entity, current)) - throw new InvalidOperationException($"{typeof(TEntity).Name} update conflict."); + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} update conflict."); return Task.CompletedTask; } - public Task DeleteAsync(TKey key, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (!_store.TryGetValue(key, out var current)) - return Task.CompletedTask; + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + BeforeDelete(current, expectedVersion, mode, now); if (mode == DeleteMode.Hard) { - _store.TryRemove(key, out _); + if (!_store.TryRemove(new KeyValuePair(key, current))) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + return Task.CompletedTask; - }; + } - var original = current; - if (current is ISoftDeletable soft) - { - if (soft.IsDeleted) - return Task.CompletedTask; + var next = Snapshot(current); - soft.MarkDeleted(now); - current.Version++; + if (next is not ISoftDeletable soft) + throw new UAuthConflictException($"{typeof(TEntity).Name} does not support soft delete."); - if (!_store.TryUpdate(key, current, original)) - throw new UAuthConflictException("Delete conflict"); - } + next = soft.MarkDeleted(now); + next.Version = expectedVersion + 1; + + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); return Task.CompletedTask; } - protected IEnumerable Values => _store.Values; + protected IReadOnlyList Values() => _store.Values.Select(Snapshot).ToList().AsReadOnly(); protected bool TryGet(TKey key, out TEntity? entity) => _store.TryGetValue(key, out entity); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 3335cf3c..67077045 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -59,6 +59,27 @@ internal AccessContext( Action = action; Attributes = attributes; } + + public AccessContext WithAttribute(string key, object value) + { + var merged = new Dictionary(Attributes) + { + [key] = value + }; + + return new AccessContext( + ActorUserKey, + ActorTenant, + IsAuthenticated, + IsSystemActor, + ActorChainId, + Resource, + TargetUserKey, + ResourceTenant, + Action, + merged + ); + } } internal sealed class EmptyAttributes : IReadOnlyDictionary diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 7bb29b77..4b94858b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -142,7 +142,6 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 094fa5f2..496c3e70 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Policies.Abstractions; @@ -9,17 +10,21 @@ public sealed class UAuthAccessOrchestrator : IAccessOrchestrator { private readonly IAccessAuthority _authority; private readonly IAccessPolicyProvider _policyProvider; + private readonly IUserPermissionStore _permissions; - public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider) + public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider, IUserPermissionStore permissions) { _authority = authority; _policyProvider = policyProvider; + _permissions = permissions; } public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + context = await EnrichAsync(context, ct); + var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); @@ -36,6 +41,8 @@ public async Task ExecuteAsync(AccessContext context, IAccessC { ct.ThrowIfCancellationRequested(); + context = await EnrichAsync(context, ct); + var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); @@ -47,4 +54,13 @@ public async Task ExecuteAsync(AccessContext context, IAccessC return await command.ExecuteAsync(ct); } + + private async Task EnrichAsync(AccessContext context, CancellationToken ct) + { + if (context.ActorUserKey is null) + return context; + + var perms = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); + return context.WithAttribute("permissions", perms); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs deleted file mode 100644 index bf83fcfc..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionManager.cs +++ /dev/null @@ -1,49 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Server.Services; - -internal sealed class UAuthSessionManager : IUAuthSessionManager -{ - private readonly IAuthFlowContextAccessor _authFlow; - private readonly ISessionOrchestrator _orchestrator; - private readonly IClock _clock; - - public UAuthSessionManager(IAuthFlowContextAccessor authFlow, ISessionOrchestrator orchestrator, IClock clock) - { - _authFlow = authFlow; - _orchestrator = orchestrator; - _clock = clock; - } - - public Task RevokeSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeSessionCommand(sessionId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeChainAsync(SessionChainId chainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeChainCommand(chainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeAllChainsCommand(userKey, exceptChainId); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } - - public Task RevokeRootAsync(UserKey userKey, CancellationToken ct = default) - { - var authContext = _authFlow.Current.ToAuthContext(_clock.UtcNow); - var command = new RevokeRootCommand(userKey); - return _orchestrator.ExecuteAsync(authContext, command, ct); - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs new file mode 100644 index 00000000..3adb7f27 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct Permission(string Value) +{ + public static Permission From(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("permission_required"); + + return new Permission(value.Trim().ToLowerInvariant()); + } + + public static readonly Permission Wildcard = new("*"); + + public override string ToString() => Value; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs new file mode 100644 index 00000000..a2411ad4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleQuery.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class RoleQuery : PageRequest +{ + public string? Search { get; set; } + public bool IncludeDeleted { get; set; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs index d4698be1..b62ad6b2 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,6 +9,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { + services.TryAddSingleton(); services.TryAddSingleton(); // Never try add - seeding is enumerated and all contributors are added. diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs index 7bc3b7ac..14d53248 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -9,22 +11,31 @@ internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor { public int Order => 20; + private readonly IRoleStore _roleStore; private readonly IUserRoleStore _roles; private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; - public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserIdProvider ids) + public InMemoryAuthorizationSeedContributor(IRoleStore roleStore, IUserRoleStore roles, IInMemoryUserIdProvider ids, IClock clock) { + _roleStore = roleStore; _roles = roles; _ids = ids; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) { + var adminRoleId = RoleId.From(Guid.NewGuid()); + var userRoleId = RoleId.From(Guid.NewGuid()); + await _roleStore.AddAsync(Role.Create(adminRoleId, tenant, "Admin", new HashSet() { Permission.Wildcard }, _clock.UtcNow)); + await _roleStore.AddAsync(Role.Create(userRoleId, tenant, "User", null, _clock.UtcNow)); + var adminKey = _ids.GetAdminUserId(); - await _roles.AssignAsync(tenant, adminKey, "Admin", ct); - await _roles.AssignAsync(tenant, adminKey, "User", ct); + await _roles.AssignAsync(tenant, adminKey, adminRoleId, ct); + await _roles.AssignAsync(tenant, adminKey, userRoleId, ct); var userKey = _ids.GetUserUserId(); - await _roles.AssignAsync(tenant, userKey, "User", ct); + await _roles.AssignAsync(tenant, userKey, userRoleId, ct); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs new file mode 100644 index 00000000..f2c4e849 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -0,0 +1,128 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryRoleStore : InMemoryVersionedStore, IRoleStore +{ + protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + + protected override void BeforeAdd(Role entity) + { + if (Values().Any(r => + r.Tenant == entity.Tenant && + r.NormalizedName == entity.NormalizedName && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_already_exists"); + } + } + + protected override void BeforeSave(Role entity, Role current, long expectedVersion) + { + if (entity.NormalizedName != current.NormalizedName) + { + if (Values().Any(r => + r.Tenant == entity.Tenant && + r.NormalizedName == entity.NormalizedName && + r.Id != entity.Id && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_name_already_exists"); + } + } + } + + public Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var role = Values() + .FirstOrDefault(r => + r.Tenant == tenant && + r.NormalizedName == normalizedName && + !r.IsDeleted); + + return Task.FromResult(role); + } + + public Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = new List(roleIds.Count); + + foreach (var id in roleIds) + { + if (TryGet(new RoleKey(tenant, id), out var role) && role is not null) + { + result.Add(role.Snapshot()); + } + } + + return Task.FromResult>(result); + } + + public Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = Values() + .Where(r => r.Tenant == tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(r => !r.IsDeleted); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToUpperInvariant(); + + baseQuery = baseQuery.Where(r => + r.NormalizedName.Contains(search)); + } + + baseQuery = query.SortBy switch + { + nameof(Role.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(Role.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(Role.Name) => query.Descending + ? baseQuery.OrderByDescending(x => x.Name) + : baseQuery.OrderBy(x => x.Name), + + nameof(Role.NormalizedName) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedName) + : baseQuery.OrderBy(x => x.NormalizedName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 4616d46c..7d05acd9 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; @@ -6,37 +7,37 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryUserRoleStore : IUserRoleStore { - private readonly ConcurrentDictionary<(TenantKey Tenant, UserKey UserKey), HashSet> _roles = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), HashSet> _roles = new(); - public Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (_roles.TryGetValue((tenant, userKey), out var set)) { lock (set) - { - return Task.FromResult>(set.ToArray()); - } + return Task.FromResult>(set.ToArray()); } - return Task.FromResult>(Array.Empty()); + return Task.FromResult>(Array.Empty()); } - public Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var set = _roles.GetOrAdd((tenant, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + var set = _roles.GetOrAdd((tenant, userKey), + _ => new HashSet()); + lock (set) { - set.Add(role); + set.Add(roleId); } return Task.CompletedTask; } - public Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default) + public Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -44,7 +45,7 @@ public Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, Cancella { lock (set) { - set.Remove(role); + set.Remove(roleId); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj index 345a98ba..8747bbb6 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs deleted file mode 100644 index 15597431..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; - -public sealed class Role : IVersionedEntity -{ - public required string Name { get; init; } - public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); - - public long Version { get; set; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/UserRole.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/UserRole.cs new file mode 100644 index 00000000..5c3efe6d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/UserRole.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +public sealed class UserRole +{ + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + + public RoleId RoleId { get; init; } + + public DateTimeOffset AssignedAt { get; init; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index f35f7126..5413ca6e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; @@ -12,14 +12,14 @@ public sealed class AuthorizationEndpointHandler : IAuthorizationEndpointHandler { private readonly IAuthFlowContextAccessor _authFlow; private readonly IAuthorizationService _authorization; - private readonly IUserRoleService _roles; + private readonly IUserRoleService _userRoles; private readonly IAccessContextFactory _accessContextFactory; - public AuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles, IAccessContextFactory accessContextFactory) + public AuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService userRoles, IAccessContextFactory accessContextFactory) { _authFlow = authFlow; _authorization = authorization; - _roles = roles; + _userRoles = userRoles; _accessContextFactory = accessContextFactory; } @@ -57,8 +57,10 @@ public async Task CheckAsync(HttpContext ctx) public async Task GetMyRolesAsync(HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.ReadSelf, @@ -66,7 +68,7 @@ public async Task GetMyRolesAsync(HttpContext ctx) resourceId: flow.UserKey!.Value ); - var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, flow.UserKey!.Value, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { UserKey = flow.UserKey!.Value, @@ -88,7 +90,7 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - var roles = await _roles.GetRolesAsync(accessContext, userKey, ctx.RequestAborted); + var roles = await _userRoles.GetRolesAsync(accessContext, userKey, ctx.RequestAborted); return Results.Ok(new UserRolesResponse { @@ -112,7 +114,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.AssignAsync(accessContext, userKey, req.Role, ctx.RequestAborted); return Results.Ok(); } @@ -131,7 +133,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) resourceId: userKey.Value ); - await _roles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); + await _userRoles.RemoveAsync(accessContext, userKey, req.Role, ctx.RequestAborted); return Results.Ok(); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs index 3142d271..49c5f58a 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs @@ -9,9 +9,10 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) { services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); return services; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs index 4f5d5a7b..e30a12a9 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -1,34 +1,33 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class RolePermissionResolver : IRolePermissionResolver +internal sealed class RolePermissionResolver : IRolePermissionResolver { - private static readonly IReadOnlyDictionary _map - = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["admin"] = new[] - { - new Permission("*") - }, - ["user"] = new[] - { - new Permission("profile.read"), - new Permission("profile.update") - } - }; - - public Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default) + private readonly IRoleStore _roles; + + public RolePermissionResolver(IRoleStore roles) { - var result = new List(); + _roles = roles; + } + + public async Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + if (roleIds.Count == 0) + return Array.Empty(); + + var roles = await _roles.GetByIdsAsync(tenant, roleIds, ct); + + var permissions = new HashSet(); foreach (var role in roles) { - if (_map.TryGetValue(role, out var perms)) - result.AddRange(perms); + foreach (var perm in role.Permissions) + permissions.Add(perm); } - return Task.FromResult>(result); + return permissions.ToArray(); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs index 150eae91..997cafb3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -1,23 +1,23 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization.Reference; -public sealed class UserPermissionStore : IUserPermissionStore +internal sealed class UserPermissionStore : IUserPermissionStore { - private readonly IUserRoleStore _roles; + private readonly IUserRoleStore _userRoles; private readonly IRolePermissionResolver _resolver; - public UserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + public UserPermissionStore(IUserRoleStore userRoles, IRolePermissionResolver resolver) { - _roles = roles; + _userRoles = userRoles; _resolver = resolver; } public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + var roles = await _userRoles.GetRolesAsync(tenant, userKey, ct); return await _resolver.ResolveAsync(tenant, roles, ct); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs index 3b01d601..2379e284 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Abstractions; @@ -7,30 +8,38 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class AuthorizationService : IAuthorizationService { + private readonly IUserPermissionStore _permissions; private readonly IAccessPolicyProvider _policyProvider; private readonly IAccessAuthority _accessAuthority; - public AuthorizationService(IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + public AuthorizationService(IUserPermissionStore permissions, IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) { + _permissions = permissions; _policyProvider = policyProvider; _accessAuthority = accessAuthority; } - public Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + public async Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var policies = _policyProvider.GetPolicies(context); - var decision = _accessAuthority.Decide(context, policies); + IReadOnlyCollection permissions = Array.Empty(); + + if (context.ActorUserKey is not null) + { + permissions = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); + } + + var enrichedContext = context.WithAttribute("permissions", permissions); + + var policies = _policyProvider.GetPolicies(enrichedContext); + var decision = _accessAuthority.Decide(enrichedContext, policies); if (decision.RequiresReauthentication) - return Task.FromResult(AuthorizationResult.ReauthRequired()); + return AuthorizationResult.ReauthRequired(); - return Task.FromResult( - decision.IsAllowed - ? AuthorizationResult.Allow() - : AuthorizationResult.Deny(decision.DenyReason) - ); + return decision.IsAllowed + ? AuthorizationResult.Allow() + : AuthorizationResult.Deny(decision.DenyReason); } - } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs new file mode 100644 index 00000000..d2e90e1c --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -0,0 +1,109 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class RoleService : IRoleService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IRoleStore _roles; + private readonly IClock _clock; + + public RoleService(IAccessOrchestrator accessOrchestrator, IRoleStore roles, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _roles = roles; + _clock = clock; + } + + public async Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var role = Role.Create(RoleId.New(), context.ResourceTenant, name, permissions, _clock.UtcNow); + await _roles.AddAsync(role, innerCt); + + return role; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.Rename(newName, _clock.UtcNow); + + await _roles.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.GetAsync(key, innerCt); + + if (role is null) + throw new UAuthNotFoundException("role_not_found"); + + await _roles.DeleteAsync(key, role.Version, mode, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await _roles.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.SetPermissions(permissions, _clock.UtcNow); + + await _roles.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand>(async innerCt => + { + return await _roles.QueryAsync(context.ResourceTenant, query, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 1def14bf..fac9c6cb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -7,52 +7,63 @@ namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class UserRoleService : IUserRoleService { private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserRoleStore _store; + private readonly IUserRoleStore _userRoles; + private readonly IRoleStore _roles; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore store) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStore userRoles, IRoleStore roles) { _accessOrchestrator = accessOrchestrator; - _store = store; + _userRoles = userRoles; + _roles = roles; } - public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); - var cmd = new AssignUserRoleCommand( - async innerCt => - { - await _store.AssignAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + if (role is null || role.IsDeleted) + throw new InvalidOperationException("role_not_found"); + + await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default) + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(role)) - throw new ArgumentException("role_empty", nameof(role)); + var cmd = new AccessCommand(async innerCt => + { + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); + + if (role is null) + return; - var cmd = new RemoveUserRoleCommand( - async innerCt => - { - await _store.RemoveAsync(context.ResourceTenant, targetUserKey, role, innerCt); - }); + await _userRoles.RemoveAsync(context.ResourceTenant, targetUserKey, role.Id, innerCt); + }); await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new GetUserRolesCommand(innerCt => _store.GetRolesAsync(context.ResourceTenant, targetUserKey, innerCt)); + var cmd = new AccessCommand>(async innerCt => + { + var roleIds = await _userRoles.GetRolesAsync(context.ResourceTenant, targetUserKey, innerCt); + var roles = await _roles.GetByIdsAsync(context.ResourceTenant, roleIds, innerCt); + + return roles.Select(r => r.Name).ToArray(); + }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs index d6e09167..119d5002 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IRolePermissionResolver { - Task> ResolveAsync(TenantKey tenant, IEnumerable roles, CancellationToken ct = default); + Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roles, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs new file mode 100644 index 00000000..51e734e9 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleService +{ + Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default); + + Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default); + + Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default); + + Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs new file mode 100644 index 00000000..cb1dc09b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleStore : IVersionedStore +{ + Task GetByNameAsync(TenantKey tenant, string normalizedName, CancellationToken ct = default); + Task> GetByIdsAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default); + Task> QueryAsync(TenantKey tenant, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs index db519dc2..eb2aabaa 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs index 3c9a4f29..c2fe6013 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleService { - Task AssignAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); - Task RemoveAsync(AccessContext context, UserKey targetUserKey, string role, CancellationToken ct = default); + Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); + Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs index 028f5f6a..b483889c 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; public interface IUserRoleStore { - Task AssignAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task RemoveAsync(TenantKey tenant, UserKey userKey, string role, CancellationToken ct = default); - Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task> GetRolesAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default); + Task RemoveAsync(TenantKey tenant, UserKey userKey, RoleId roleId, CancellationToken ct = default); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs index eb58589c..67c3c457 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs @@ -8,17 +8,20 @@ namespace CodeBeam.UltimateAuth.Authorization; public sealed class AuthorizationClaimsProvider : IUserClaimsProvider { private readonly IUserRoleStore _roles; + private readonly IRoleStore _roleStore; private readonly IUserPermissionStore _permissions; - public AuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + public AuthorizationClaimsProvider(IUserRoleStore roles, IRoleStore roleStore, IUserPermissionStore permissions) { _roles = roles; + _roleStore = roleStore; _permissions = permissions; } public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var roles = await _roles.GetRolesAsync(tenant, userKey, ct); + var roleIds = await _roles.GetRolesAsync(tenant, userKey, ct); + var roles = await _roleStore.GetByIdsAsync(tenant, roleIds, ct); var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); var builder = ClaimsSnapshot.Create(); @@ -26,7 +29,7 @@ public async Task GetClaimsAsync(TenantKey tenant, UserKey userK builder.Add("uauth:tenant", tenant.Value); foreach (var role in roles) - builder.Add(ClaimTypes.Role, role); + builder.Add(ClaimTypes.Role, role.Name); foreach (var perm in perms) builder.Add("uauth:permission", perm.Value); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj index ce41f1eb..d1493e50 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -12,6 +12,7 @@ + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs deleted file mode 100644 index 69dc0f07..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Domain; - -public readonly record struct Permission(string Value) -{ - public override string ToString() => Value; -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs new file mode 100644 index 00000000..c1f264b0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -0,0 +1,134 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class Role : IVersionedEntity, IEntitySnapshot, ISoftDeletable +{ + private readonly HashSet _permissions = new(); + + public RoleId Id { get; private set; } + public TenantKey Tenant { get; private set; } + + public string Name { get; private set; } = default!; + public string NormalizedName { get; private set; } = default!; + + public IReadOnlyCollection Permissions => _permissions.ToArray(); + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + + private Role() { } + + public static Role Create( + RoleId? id, + TenantKey tenant, + string name, + IEnumerable? permissions, + DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("role_name_required"); + + var normalized = Normalize(name); + + var role = new Role + { + Id = id ?? RoleId.New(), + Tenant = tenant, + Name = name.Trim(), + NormalizedName = normalized, + CreatedAt = now, + Version = 0 + }; + + if (permissions is not null) + { + foreach (var p in permissions) + role._permissions.Add(p); + } + + return role; + } + + public Role Rename(string newName, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new InvalidOperationException("role_name_required"); + + if (NormalizedName == Normalize(newName)) + return this; + + Name = newName.Trim(); + NormalizedName = Normalize(newName); + UpdatedAt = now; + + return this; + } + + public Role AddPermission(Permission permission, DateTimeOffset now) + { + _permissions.Add(permission); + UpdatedAt = now; + return this; + } + + public Role RemovePermission(Permission permission, DateTimeOffset now) + { + _permissions.Remove(permission); + UpdatedAt = now; + return this; + } + + public Role SetPermissions(IEnumerable permissions, DateTimeOffset now) + { + _permissions.Clear(); + + foreach (var p in permissions) + _permissions.Add(p); + + UpdatedAt = now; + return this; + } + + public Role MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } + + public Role Snapshot() + { + var copy = new Role + { + Id = Id, + Tenant = Tenant, + Name = Name, + NormalizedName = NormalizedName, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + + foreach (var p in _permissions) + copy._permissions.Add(p); + + return copy; + } + + private static string Normalize(string name) + => name.Trim().ToUpperInvariant(); +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleId.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleId.cs new file mode 100644 index 00000000..2d09cc95 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleId.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Authorization.Domain; + +public readonly record struct RoleId(Guid Value) +{ + public static RoleId New() => new(Guid.NewGuid()); + + public static RoleId From(Guid guid) => new(guid); + + public override string ToString() => Value.ToString(); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs new file mode 100644 index 00000000..62202cb8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Domain; + +public readonly record struct RoleKey( + TenantKey Tenant, + RoleId RoleId); \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs index 2a6848a4..43615172 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs @@ -1,29 +1,29 @@ -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Authorization.Contracts; +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Authorization; +//namespace CodeBeam.UltimateAuth.Authorization; -public sealed class PermissionAccessPolicy : IAccessPolicy -{ - private readonly IReadOnlySet _permissions; - private readonly string _operation; +//public sealed class PermissionAccessPolicy : IAccessPolicy +//{ +// private readonly IReadOnlySet _permissions; +// private readonly string _operation; - public PermissionAccessPolicy(IEnumerable permissions, string operation) - { - _permissions = permissions.Select(p => p.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); - _operation = operation; - } +// public PermissionAccessPolicy(IEnumerable permissions, string operation) +// { +// _permissions = permissions.Select(p => p.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); +// _operation = operation; +// } - public bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; +// public bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; - public AccessDecision Decide(AccessContext context) - { - if (context.ActorUserKey is null) - return AccessDecision.Deny("unauthenticated"); +// public AccessDecision Decide(AccessContext context) +// { +// if (context.ActorUserKey is null) +// return AccessDecision.Deny("unauthenticated"); - return _permissions.Contains(_operation) - ? AccessDecision.Allow() - : AccessDecision.Deny("missing_permission"); - } -} +// return _permissions.Contains(_operation) +// ? AccessDecision.Allow() +// : AccessDecision.Deny("missing_permission"); +// } +//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs new file mode 100644 index 00000000..aa6a924e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public readonly record struct CredentialKey( + TenantKey Tenant, + Guid Id); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 3a06c757..27436d4a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -8,6 +8,9 @@ public sealed class CredentialSecurityState public DateTimeOffset? ExpiresAt { get; } public Guid SecurityStamp { get; } + public bool IsRevoked => RevokedAt != null; + public bool IsExpired => ExpiresAt != null; + public CredentialSecurityState( DateTimeOffset? revokedAt = null, DateTimeOffset? expiresAt = null, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs index 42cfd84f..e4ef841f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -5,7 +5,6 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -using Microsoft.AspNetCore.DataProtection; namespace CodeBeam.UltimateAuth.Credentials.InMemory; @@ -18,12 +17,14 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor private readonly ICredentialStore _credentials; private readonly IInMemoryUserIdProvider _ids; private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; - public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock) { _credentials = credentials; _ids = ids; _hasher = hasher; + _clock = clock; } public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) @@ -37,7 +38,6 @@ private async Task SeedCredentialAsync(UserKey userKey, Guid credentialId, strin try { await _credentials.AddAsync( - tenant, PasswordCredential.Create( credentialId, tenant, @@ -45,7 +45,7 @@ await _credentials.AddAsync( _hasher.Hash(secretHash), CredentialSecurityState.Active(), new CredentialMetadata(), - DateTimeOffset.UtcNow), + _clock.UtcNow), ct); } catch (UAuthConflictException) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 933ea00c..65d6f39c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -3,21 +3,20 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -using System.Collections.Concurrent; -using System.Text; namespace CodeBeam.UltimateAuth.Credentials.InMemory; -internal sealed class InMemoryCredentialStore : ICredentialStore +internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore { - private readonly ConcurrentDictionary<(TenantKey, Guid), PasswordCredential> _store = new(); + protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var result = _store.Values + var result = Values() .Where(c => c.Tenant == tenant && c.UserKey == userKey) .Cast() .ToArray(); @@ -25,109 +24,64 @@ public Task> GetByUserAsync(TenantKey tenant, U return Task.FromResult>(result); } - public Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default) + public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _store.TryGetValue((tenant, credentialId), out var credential); - return Task.FromResult(credential); + if (TryGet(key, out var entity)) + return Task.FromResult(entity); + + return Task.FromResult(entity); } - public Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default) + public Task AddAsync(ICredential credential, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - // TODO: Support other credential types if needed. For now, we only have PasswordCredential in-memory. + // TODO: Implement other credential types if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var key = (tenant, pwd.Id); - - if (!_store.TryAdd(key, pwd)) - throw new UAuthConflictException("credential_already_exists"); - - return Task.CompletedTask; + return base.AddAsync(pwd, ct); } - public Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedVersion, CancellationToken ct = default) + public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - if (credential is not PasswordCredential pwd) throw new NotSupportedException("Only password credentials are supported in-memory."); - var key = (tenant, pwd.Id); - - if (!_store.TryGetValue(key, out var current)) - throw new UAuthNotFoundException("credential_not_found"); - - if (current.Version != expectedVersion) - throw new UAuthConflictException("credential_version_conflict"); - - _store.TryUpdate(key, pwd, current); - - return Task.CompletedTask; + return base.SaveAsync(pwd, expectedVersion, ct); } - public Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) + public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var key = (tenant, credentialId); - - if (!_store.TryGetValue(key, out var credential)) + if (!TryGet(key, out var credential)) throw new UAuthNotFoundException("credential_not_found"); - if (credential.Version != expectedVersion) - throw new UAuthConflictException("credential_version_conflict"); - - if (credential.IsRevoked) - return Task.CompletedTask; + if (credential is not PasswordCredential pwd) + throw new NotSupportedException("Only password credentials are supported in-memory."); - var updated = credential.Revoke(revokedAt); - _store[key] = updated; + var revoked = pwd.Revoke(revokedAt); - return Task.CompletedTask; + return SaveAsync(revoked, expectedVersion, ct); } - public Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) + public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - var key = (tenant, credentialId); - - if (!_store.TryGetValue(key, out var credential)) - throw new UAuthNotFoundException("credential_not_found"); - - if (credential.Version != expectedVersion) - throw new UAuthConflictException("credential_version_conflict"); - - if (mode == DeleteMode.Hard) - { - _store.TryRemove(key, out _); - return Task.CompletedTask; - } - - if (!credential.IsRevoked) - { - var updated = credential.Revoke(now); - _store[key] = updated; - } - - return Task.CompletedTask; + return base.DeleteAsync(key, expectedVersion, mode, now, ct); } public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var credentials = _store.Values.Where(c => c.Tenant == tenant && c.UserKey == userKey).ToList(); + var credentials = Values() + .Where(c => c.Tenant == tenant && c.UserKey == userKey) + .ToList(); foreach (var credential in credentials) { - ct.ThrowIfCancellationRequested(); - - await DeleteAsync(tenant, credential.Id, mode, now, credential.Version, ct); + await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct); } } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index 636624e9..9650be6c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -6,26 +6,28 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; -public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity +public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable { public Guid Id { get; init; } public TenantKey Tenant { get; init; } public UserKey UserKey { get; init; } public CredentialType Type => CredentialType.Password; - public string SecretHash { get; init; } = default!; - public CredentialSecurityState Security { get; init; } = CredentialSecurityState.Active(); - public CredentialMetadata Metadata { get; init; } = new CredentialMetadata(); + public string SecretHash { get; private set; } = default!; + public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active(); + public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata(); public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } public long Version { get; set; } public bool IsRevoked => Security.RevokedAt is not null; + public bool IsDeleted => DeletedAt is not null; public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; - public PasswordCredential() { } + private PasswordCredential() { } private PasswordCredential( Guid id, @@ -36,58 +38,64 @@ private PasswordCredential( CredentialMetadata metadata, DateTimeOffset createdAt, DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, long version) { - if (id == Guid.Empty) throw new UAuthValidationException("credential_id_required"); - if (string.IsNullOrWhiteSpace(secretHash)) throw new UAuthValidationException("credential_secret_required"); + if (id == Guid.Empty) + throw new UAuthValidationException("credential_id_required"); + + if (string.IsNullOrWhiteSpace(secretHash)) + throw new UAuthValidationException("credential_secret_required"); Id = id; Tenant = tenant; UserKey = userKey; - SecretHash = !string.IsNullOrWhiteSpace(secretHash) - ? secretHash - : throw new UAuthValidationException("credential_secret_required"); - Security = security; + SecretHash = secretHash; + Security = security ?? CredentialSecurityState.Active(); Metadata = metadata ?? new CredentialMetadata(); CreatedAt = createdAt; UpdatedAt = updatedAt; + DeletedAt = deletedAt; Version = version; } + public PasswordCredential Snapshot() + { + return new PasswordCredential + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + SecretHash = SecretHash, + Security = Security, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + Version = Version + }; + } + public static PasswordCredential Create( - Guid? id, - TenantKey tenant, - UserKey userKey, - string secretHash, - CredentialSecurityState security, - CredentialMetadata metadata, - DateTimeOffset now) - => new( - id: id ?? Guid.NewGuid(), - tenant: tenant, - userKey: userKey, - secretHash: secretHash, - security: security, - metadata: metadata, - createdAt: now, - updatedAt: null, - version: 0); - - private PasswordCredential Next( - string? secretHash = null, - CredentialSecurityState? security = null, - CredentialMetadata? metadata = null, - DateTimeOffset? updatedAt = null) - => new( - id: Id, - tenant: Tenant, - userKey: UserKey, - secretHash: secretHash ?? SecretHash, - security: security ?? Security, - metadata: metadata ?? Metadata, - createdAt: CreatedAt, - updatedAt: updatedAt ?? UpdatedAt, - version: Version + 1); + Guid? id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset now) + { + return new PasswordCredential( + id ?? Guid.NewGuid(), + tenant, + userKey, + secretHash, + security ?? CredentialSecurityState.Active(), + metadata ?? new CredentialMetadata(), + now, + null, + null, + 0); + } public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) { @@ -103,17 +111,43 @@ public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) if (string.Equals(SecretHash, newSecretHash, StringComparison.Ordinal)) throw new UAuthValidationException("credential_secret_same"); - return Next(newSecretHash, Security.RotateStamp(), updatedAt: now); + SecretHash = newSecretHash; + Security = Security.RotateStamp(); + UpdatedAt = now; + + return this; } public PasswordCredential SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) - => Next(security: Security.SetExpiry(expiresAt), updatedAt: now); + { + if (IsExpired(now)) + return this; + + Security = Security.SetExpiry(expiresAt); + UpdatedAt = now; + + return this; + } public PasswordCredential Revoke(DateTimeOffset now) { if (IsRevoked) return this; - return Next(security: Security.Revoke(now), updatedAt: now); + Security = Security.Revoke(now); + UpdatedAt = now; + + return this; + } + + public PasswordCredential MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs index 706f8b69..efd94263 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -37,10 +37,10 @@ public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object r userKey: userKey, secretHash: hash, security: CredentialSecurityState.Active(), - metadata: new CredentialMetadata { LastUsedAt = _clock.UtcNow }, + metadata: new CredentialMetadata { }, _clock.UtcNow); - await _credentialStore.AddAsync(tenant, credential, ct); + await _credentialStore.AddAsync(credential, ct); } public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 09c2aea4..bfb3dbd5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -102,7 +102,7 @@ public async Task AddAsync(AccessContext context, AddCreden metadata: new CredentialMetadata(), now: now); - await _credentials.AddAsync(context.ResourceTenant, credential, innerCt); + await _credentials.AddAsync(credential, innerCt); return AddCredentialResult.Success(credential.Id, credential.Type); }); @@ -144,7 +144,7 @@ public async Task ChangeSecretAsync(AccessContext contex var oldVersion = pwd.Version; var newHash = _hasher.Hash(request.NewSecret); var updated = pwd.ChangeSecret(newHash, now); - await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); + await _credentials.SaveAsync(updated, oldVersion, innerCt); return ChangeCredentialResult.Success(pwd.Type); }); @@ -161,7 +161,7 @@ public async Task RevokeAsync(AccessContext context, Rev var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -171,7 +171,7 @@ public async Task RevokeAsync(AccessContext context, Rev var oldVersion = pwd.Version; var updated = pwd.Revoke(now); - await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); + await _credentials.SaveAsync(updated, oldVersion, innerCt); return CredentialActionResult.Success(); }); @@ -299,7 +299,7 @@ public async Task CompleteResetAsync(AccessContext conte var newHash = _hasher.Hash(request.NewSecret); var updated = pwd.ChangeSecret(newHash, now); - await _credentials.UpdateAsync(context.ResourceTenant, updated, oldVersion, innerCt); + await _credentials.SaveAsync(updated, oldVersion, innerCt); return CredentialActionResult.Success(); }); @@ -339,7 +339,7 @@ public async Task DeleteAsync(AccessContext context, Del var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; - var credential = await _credentials.GetByIdAsync(context.ResourceTenant, request.Id, innerCt); + var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); if (credential is not PasswordCredential pwd) return CredentialActionResult.Fail("credential_not_found"); @@ -348,7 +348,7 @@ public async Task DeleteAsync(AccessContext context, Del return CredentialActionResult.Fail("credential_not_found"); var oldVersion = pwd.Version; - await _credentials.DeleteAsync(context.ResourceTenant, pwd.Id, request.Mode, now, oldVersion, innerCt); + await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, innerCt); return CredentialActionResult.Success(); }); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs index f2f9879b..a9cdc74b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -1,10 +1,12 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredential { + Guid Id { get; } + TenantKey Tenant { get; init; } UserKey UserKey { get; init; } CredentialType Type { get; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs index 670c1d2b..1e2f3b86 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -1,16 +1,17 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials; public interface ICredentialStore { Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task GetByIdAsync(TenantKey tenant, Guid credentialId, CancellationToken ct = default); - Task AddAsync(TenantKey tenant, ICredential credential, CancellationToken ct = default); - Task UpdateAsync(TenantKey tenant, ICredential credential, long expectedVersion, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, Guid credentialId, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); - Task DeleteAsync(TenantKey tenant, Guid credentialId, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); + Task GetByIdAsync(CredentialKey key, CancellationToken ct = default); + Task AddAsync(ICredential credential, CancellationToken ct = default); + Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default); + Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj index ce41f1eb..6db7b9d7 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -11,6 +11,7 @@ + diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index f63214b0..45fc9fd8 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -18,5 +19,8 @@ public static void Register(AccessPolicyRegistry registry) registry.Add("", _ => new RequireAdminPolicy()); registry.Add("", _ => new RequireSelfOrAdminPolicy()); registry.Add("", _ => new RequireSystemPolicy()); + + // Permission + registry.Add("", _ => new MustHavePermissionPolicy()); } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs new file mode 100644 index 00000000..573b4744 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Policies; + +public sealed class MustHavePermissionPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (context.Attributes.TryGetValue("permissions", out var value) && value is IReadOnlyCollection permissions) + { + var actionPermission = Permission.From(context.Action); + + if (permissions.Contains(actionPermission) || + permissions.Contains(Permission.Wildcard)) + { + return AccessDecision.Allow(); + } + } + + return AccessDecision.Deny("missing_permission"); + } + + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".admin"); + } +} \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index becf9d37..595fde6b 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Policies; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs index afd1e762..2335b208 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -47,64 +47,43 @@ private async Task SeedUserAsync(TenantKey tenant, UserKey userKey, string displ if (await _lifecycle.ExistsAsync(userLifecycleKey, ct)) return; - await _lifecycle.CreateAsync( - UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); + await _lifecycle.AddAsync(UserLifecycle.Create(tenant, userKey, _clock.UtcNow), ct); + await _profiles.AddAsync(UserProfile.Create(_clock.UtcNow, tenant, userKey, displayName: displayName), ct); - await _profiles.CreateAsync(tenant, - new UserProfile - { - Tenant = tenant, - UserKey = userKey, - DisplayName = displayName, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + username, + _identifierNormalizer.Normalize(UserIdentifierType.Username, username).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = username, - NormalizedValue = _identifierNormalizer - .Normalize(UserIdentifierType.Username, username) - .Normalized, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Email, + email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, email).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Email, - Value = email, - NormalizedValue = _identifierNormalizer - .Normalize(UserIdentifierType.Email, email) - .Normalized, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); - - await _identifiers.CreateAsync(tenant, - new UserIdentifier - { - Id = Guid.NewGuid(), - Tenant = tenant, - UserKey = userKey, - Type = UserIdentifierType.Phone, - Value = phone, - NormalizedValue = _identifierNormalizer - .Normalize(UserIdentifierType.Phone, phone) - .Normalized, - IsPrimary = true, - IsVerified = true, - CreatedAt = _clock.UtcNow - }, ct); + await _identifiers.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Phone, + phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, phone).Normalized, + _clock.UtcNow, + true, + _clock.UtcNow), ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 535f6d7a..af8fbcb8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -7,263 +8,170 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore +public sealed class InMemoryUserIdentifierStore : InMemoryVersionedStore, IUserIdentifierStore { - private readonly Dictionary _store = new(); - private readonly object _lock = new(); + protected override Guid GetKey(UserIdentifier entity) => entity.Id; public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { - lock (_lock) + ct.ThrowIfCancellationRequested(); + + var candidates = Values() + .Where(x => + x.Tenant == query.Tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); + + if (query.ExcludeIdentifierId.HasValue) + candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + candidates = query.Scope switch { - ct.ThrowIfCancellationRequested(); + IdentifierExistenceScope.WithinUser => + candidates.Where(x => x.UserKey == query.UserKey), - var candidates = _store.Values - .Where(x => - x.Tenant == query.Tenant && - x.Type == query.Type && - x.NormalizedValue == query.NormalizedValue && - !x.IsDeleted); + IdentifierExistenceScope.TenantPrimaryOnly => + candidates.Where(x => x.IsPrimary), - if (query.ExcludeIdentifierId.HasValue) - candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + IdentifierExistenceScope.TenantAny => + candidates, - candidates = query.Scope switch - { - IdentifierExistenceScope.WithinUser => candidates.Where(x => x.UserKey == query.UserKey), - IdentifierExistenceScope.TenantPrimaryOnly => candidates.Where(x => x.IsPrimary), - IdentifierExistenceScope.TenantAny => candidates, - _ => candidates - }; + _ => candidates + }; - var match = candidates.FirstOrDefault(); + var match = candidates.FirstOrDefault(); - if (match is null) - return Task.FromResult(new IdentifierExistenceResult(false)); + if (match is null) + return Task.FromResult(new IdentifierExistenceResult(false)); - return Task.FromResult(new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); - } + return Task.FromResult( + new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); } public Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - lock (_lock) - { - var identifier = _store.Values.FirstOrDefault(x => - x.Tenant == tenant && - x.Type == type && - x.NormalizedValue == normalizedValue && - !x.IsDeleted); + var identifier = Values() + .FirstOrDefault(x => + x.Tenant == tenant && + x.Type == type && + x.NormalizedValue == normalizedValue && + !x.IsDeleted); - return Task.FromResult(identifier?.Cloned()); - } + return Task.FromResult(identifier); } public Task GetByIdAsync(Guid id, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); - - lock (_lock) - { - if (!_store.TryGetValue(id, out var identifier)) - return Task.FromResult(null); - - if (identifier.IsDeleted) - return Task.FromResult(null); - - return Task.FromResult(identifier.Cloned()); - } + return GetAsync(id, ct); } public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - lock (_lock) - { - var result = _store.Values - .Where(x => x.Tenant == tenant) - .Where(x => x.UserKey == userKey) - .Where(x => !x.IsDeleted) - .OrderBy(x => x.CreatedAt) - .Select(x => x.Cloned()) - .ToList() - .AsReadOnly(); - - return Task.FromResult>(result); - } - } + var result = Values() + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderBy(x => x.CreatedAt) + .ToList() + .AsReadOnly(); - public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) - { - lock (_lock) - { - ct.ThrowIfCancellationRequested(); - - if (query.UserKey is null) - throw new UAuthIdentifierValidationException("userKey_required"); - - var normalized = query.Normalize(); - - var baseQuery = _store.Values - .Where(x => x.Tenant == tenant) - .Where(x => x.UserKey == query.UserKey.Value); - - if (!query.IncludeDeleted) - baseQuery = baseQuery.Where(x => !x.IsDeleted); - - baseQuery = query.SortBy switch - { - nameof(UserIdentifier.Type) => - query.Descending ? baseQuery.OrderByDescending(x => x.Type) - : baseQuery.OrderBy(x => x.Type), - - nameof(UserIdentifier.CreatedAt) => - query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) - : baseQuery.OrderBy(x => x.CreatedAt), - - _ => baseQuery.OrderBy(x => x.CreatedAt) - }; - - var totalCount = baseQuery.Count(); - - var items = baseQuery - .Skip((normalized.PageNumber - 1) * normalized.PageSize) - .Take(normalized.PageSize) - .Select(x => x.Cloned()) - .ToList() - .AsReadOnly(); - - return Task.FromResult( - new PagedResult( - items, - totalCount, - normalized.PageNumber, - normalized.PageSize, - query.SortBy, - query.Descending)); - } + return Task.FromResult>(result); } - public Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + protected override void BeforeSave(UserIdentifier entity, UserIdentifier current, long expectedVersion) { - ct.ThrowIfCancellationRequested(); - - lock (_lock) + if (!entity.IsPrimary) + return; + + foreach (var other in Values().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) { - if (!_store.TryGetValue(entity.Id, out var existing)) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); - - if (existing.Version != expectedVersion) - throw new UAuthConcurrencyException("identifier_concurrency_conflict"); - - if (entity.IsPrimary) - { - foreach (var other in _store.Values.Where(x => - x.Tenant == entity.Tenant && - x.UserKey == entity.UserKey && - x.Type == entity.Type && - x.Id != entity.Id && - x.IsPrimary && - !x.IsDeleted)) - { - other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); - } - } - - _store[entity.Id] = entity.Cloned(); + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); } - - return Task.CompletedTask; } - public Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default) + public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - lock (_lock) - { - if (identifier.Id == Guid.Empty) - identifier.Id = Guid.NewGuid(); - - identifier.Tenant = tenant; - - - if (identifier.IsPrimary) - { - foreach (var existing in _store.Values.Where(x => - x.Tenant == tenant && - x.UserKey == identifier.UserKey && - x.Type == identifier.Type && - x.IsPrimary && - !x.IsDeleted)) - { - existing.IsPrimary = false; - existing.UpdatedAt = identifier.CreatedAt; - } - } - - identifier.Version = 0; - _store[identifier.Id] = identifier; - } - - return Task.CompletedTask; - } + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); - public Task DeleteAsync(UserIdentifier entity, long expectedVersion, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + var normalized = query.Normalize(); - lock (_lock) - { - if (!_store.TryGetValue(entity.Id, out var identifier)) - throw new UAuthIdentifierNotFoundException("identifier_not_found"); + var baseQuery = Values() + .Where(x => x.Tenant == tenant) + .Where(x => x.UserKey == query.UserKey.Value); - if (identifier.Version != expectedVersion) - throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - if (mode == DeleteMode.Hard) - { - _store.Remove(entity.Id); - return Task.CompletedTask; - } + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserIdentifier.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(UserIdentifier.Value) => query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), + + nameof(UserIdentifier.NormalizedValue) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedValue) + : baseQuery.OrderBy(x => x.NormalizedValue), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); - _store[entity.Id] = entity.Cloned(); - } - return Task.CompletedTask; } public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - List snapshot; - - lock (_lock) - { - snapshot = _store.Values - .Where(x => x.Tenant == tenant && x.UserKey == userKey) - .Select(x => x.Cloned()) - .ToList(); - } + var identifiers = Values() + .Where(x => x.Tenant == tenant && x.UserKey == userKey && !x.IsDeleted) + .ToList(); - foreach (var identifier in snapshot) + foreach (var identifier in identifiers) { - if (mode == DeleteMode.Hard) - { - lock (_lock) - { - _store.Remove(identifier.Id); - } - } - else - { - var expected = identifier.Version; - identifier.MarkDeleted(deletedAt); - await SaveAsync(identifier, expected, ct); - } + await DeleteAsync(identifier.Id, identifier.Version, mode, deletedAt, ct); } } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 0e338363..912fd90b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -16,8 +16,7 @@ public Task> QueryAsync(TenantKey tenant, UserLifecyc var normalized = query.Normalize(); - var baseQuery = Values - .Where(x => x?.UserKey != null) + var baseQuery = Values() .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 4e5deb93..dee140cf 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,29 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; -public sealed class InMemoryUserProfileStore : IUserProfileStore +public sealed class InMemoryUserProfileStore : InMemoryVersionedStore, IUserProfileStore { - private readonly Dictionary<(TenantKey Tenant, UserKey UserKey), UserProfile> _store = new(); - - public Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - return Task.FromResult(_store.TryGetValue((tenant, userKey), out var profile) && profile.DeletedAt == null); - } - - public Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) - { - if (!_store.TryGetValue((tenant, userKey), out var profile)) - return Task.FromResult(null); - - if (profile.DeletedAt != null) - return Task.FromResult(null); - - return Task.FromResult(profile); - } + protected override UserProfileKey GetKey(UserProfile entity) + => new(entity.Tenant, entity.UserKey); public Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default) { @@ -31,11 +17,11 @@ public Task> QueryAsync(TenantKey tenant, UserProfileQu var normalized = query.Normalize(); - var baseQuery = _store.Values + var baseQuery = Values() .Where(x => x.Tenant == tenant); if (!query.IncludeDeleted) - baseQuery = baseQuery.Where(x => x.DeletedAt == null); + baseQuery = baseQuery.Where(x => !x.IsDeleted); baseQuery = query.SortBy switch { @@ -49,68 +35,34 @@ public Task> QueryAsync(TenantKey tenant, UserProfileQu ? baseQuery.OrderByDescending(x => x.DisplayName) : baseQuery.OrderBy(x => x.DisplayName), + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), + + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), + _ => baseQuery.OrderBy(x => x.CreatedAt) }; var totalCount = baseQuery.Count(); - var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).ToList().AsReadOnly(); - - return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); - } - - public Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default) - { - var key = (tenant, profile.UserKey); - - if (_store.ContainsKey(key)) - throw new InvalidOperationException("UserProfile already exists."); - - _store[key] = profile; - return Task.CompletedTask; - } - - public Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) - throw new InvalidOperationException("UserProfile not found."); - - existing.FirstName = update.FirstName; - existing.LastName = update.LastName; - existing.DisplayName = update.DisplayName; - existing.BirthDate = update.BirthDate; - existing.Gender = update.Gender; - existing.Bio = update.Bio; - existing.Language = update.Language; - existing.TimeZone = update.TimeZone; - existing.Culture = update.Culture; - existing.Metadata = update.Metadata; - - existing.UpdatedAt = updatedAt; - - return Task.CompletedTask; - } - - public Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) - { - var key = (tenant, userKey); - - if (!_store.TryGetValue(key, out var profile)) - return Task.CompletedTask; - - if (mode == DeleteMode.Hard) - { - _store.Remove(key); - return Task.CompletedTask; - } - - if (profile.IsDeleted) - return Task.CompletedTask; - - profile.IsDeleted = true; - profile.DeletedAt = deletedAt; - return Task.CompletedTask; + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); } -} +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs deleted file mode 100644 index 68f2948c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Reference; - -public sealed record UserProfileUpdate -{ - public string? FirstName { get; init; } - public string? LastName { get; init; } - public string? DisplayName { get; init; } - public DateOnly? BirthDate { get; init; } - public string? Gender { get; init; } - public string? Bio { get; init; } - - public string? Language { get; init; } - public string? TimeZone { get; init; } - public string? Culture { get; init; } - - public IReadOnlyDictionary? Metadata { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs index 2d8d41ca..88151a2b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -5,31 +5,30 @@ using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record UserIdentifier : IVersionedEntity, ISoftDeletable +public sealed class UserIdentifier : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public Guid Id { get; set; } - public TenantKey Tenant { get; set; } - + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } public UserKey UserKey { get; init; } public UserIdentifierType Type { get; init; } // Email, Phone, Username - public string Value { get; set; } = default!; - public string NormalizedValue { get; set; } = default!; + public string Value { get; private set; } = default!; + public string NormalizedValue { get; private set; } = default!; - public bool IsPrimary { get; set; } - public bool IsVerified { get; set; } + public bool IsPrimary { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset? VerifiedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } public long Version { get; set; } public bool IsDeleted => DeletedAt is not null; + public bool IsVerified => VerifiedAt is not null; - public UserIdentifier Cloned() + public UserIdentifier Snapshot() { return new UserIdentifier { @@ -40,7 +39,6 @@ public UserIdentifier Cloned() Value = Value, NormalizedValue = NormalizedValue, IsPrimary = IsPrimary, - IsVerified = IsVerified, CreatedAt = CreatedAt, UpdatedAt = UpdatedAt, VerifiedAt = VerifiedAt, @@ -49,7 +47,33 @@ public UserIdentifier Cloned() }; } - public void ChangeValue(string newRawValue, string newNormalizedValue, DateTimeOffset now) + public static UserIdentifier Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + UserIdentifierType type, + string value, + string normalizedValue, + DateTimeOffset now, + bool isPrimary = false, + DateTimeOffset? verifiedAt = null) + { + return new UserIdentifier + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Type = type, + Value = value, + NormalizedValue = normalizedValue, + IsPrimary = isPrimary, + VerifiedAt = verifiedAt, + CreatedAt = now, + Version = 0 + }; + } + + public UserIdentifier ChangeValue(string newRawValue, string newNormalizedValue, DateTimeOffset now) { if (IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -60,14 +84,13 @@ public void ChangeValue(string newRawValue, string newNormalizedValue, DateTimeO Value = newRawValue; NormalizedValue = newNormalizedValue; - IsVerified = false; VerifiedAt = null; UpdatedAt = now; - Version++; + return this; } - public void MarkVerified(DateTimeOffset at) + public UserIdentifier MarkVerified(DateTimeOffset at) { if (IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); @@ -75,42 +98,41 @@ public void MarkVerified(DateTimeOffset at) if (IsVerified) throw new UAuthIdentifierConflictException("identifier_already_verified"); - IsVerified = true; VerifiedAt = at; UpdatedAt = at; - Version++; + return this; } - public void SetPrimary(DateTimeOffset at) + public UserIdentifier SetPrimary(DateTimeOffset at) { if (IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (IsPrimary) - return; + return this; IsPrimary = true; UpdatedAt = at; - Version++; + return this; } - public void UnsetPrimary(DateTimeOffset at) + public UserIdentifier UnsetPrimary(DateTimeOffset at) { if (IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!IsPrimary) - throw new UAuthIdentifierConflictException("identifier_is_not_primary_already"); + throw new UAuthIdentifierConflictException("identifier_is_not_primary"); IsPrimary = false; UpdatedAt = at; - Version++; + return this; } - public void MarkDeleted(DateTimeOffset at) + public UserIdentifier MarkDeleted(DateTimeOffset at) { if (IsDeleted) throw new UAuthIdentifierConflictException("identifier_already_deleted"); @@ -119,7 +141,7 @@ public void MarkDeleted(DateTimeOffset at) IsPrimary = false; UpdatedAt = at; - Version++; + return this; } public UserIdentifierDto ToDto() diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index e3d17778..f4bcbd3d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed class UserLifecycle : IVersionedEntity +public sealed class UserLifecycle : IVersionedEntity, ISoftDeletable, IEntitySnapshot { private UserLifecycle() { } @@ -26,6 +26,22 @@ private UserLifecycle() { } public bool IsDeleted => DeletedAt != null; public bool IsActive => !IsDeleted && Status == UserStatus.Active; + public UserLifecycle Snapshot() + { + return new UserLifecycle + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Status = Status, + SecurityVersion = SecurityVersion, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + public static UserLifecycle Create(TenantKey tenant, UserKey userKey, DateTimeOffset now, Guid? id = null) { return new UserLifecycle diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 60a84aad..ff0227b4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -5,31 +5,163 @@ namespace CodeBeam.UltimateAuth.Users.Reference; // TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) -public sealed record class UserProfile : IVersionedEntity +public sealed class UserProfile : IVersionedEntity, ISoftDeletable, IEntitySnapshot { - public TenantKey Tenant { get; set; } + private UserProfile() { } - public UserKey UserKey { get; init; } = default!; + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? DisplayName { get; set; } + public UserKey UserKey { get; init; } = default!; - public DateOnly? BirthDate { get; set; } - public string? Gender { get; set; } - public string? Bio { get; set; } + public string? FirstName { get; private set; } + public string? LastName { get; private set; } + public string? DisplayName { get; private set; } - public string? Language { get; set; } - public string? TimeZone { get; set; } - public string? Culture { get; set; } + public DateOnly? BirthDate { get; private set; } + public string? Gender { get; private set; } + public string? Bio { get; private set; } - public IReadOnlyDictionary? Metadata { get; set; } + public string? Language { get; private set; } + public string? TimeZone { get; private set; } + public string? Culture { get; private set; } - public bool IsDeleted { get; set; } + public IReadOnlyDictionary? Metadata { get; private set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; set; } - public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public string EffectiveDisplayName => DisplayName ?? $"{FirstName} {LastName}"; + + public UserProfile Snapshot() + { + return new UserProfile + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserProfile Create( + DateTimeOffset createdAt, + TenantKey tenant, + UserKey userKey, + Guid? id = null, + string? firstName = null, + string? lastName = null, + string? displayName = null, + DateOnly? birthDate = null, + string? gender = null, + string? bio = null, + string? language = null, + string? timezone = null, + string? culture = null) + { + return new UserProfile + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + FirstName = firstName, + LastName = lastName, + DisplayName = displayName, + BirthDate = birthDate, + Gender = gender, + Bio = bio, + Language = language, + TimeZone = timezone, + Culture = culture, + CreatedAt = createdAt, + UpdatedAt = null, + DeletedAt = null, + Metadata = null, + Version = 0 + }; + } + + public UserProfile UpdateName(string? firstName, string? lastName, string? displayName, DateTimeOffset now) + { + if (FirstName == firstName && + LastName == lastName && + DisplayName == displayName) + return this; + + FirstName = firstName; + LastName = lastName; + DisplayName = displayName; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdatePersonalInfo(DateOnly? birthDate, string? gender, string? bio, DateTimeOffset now) + { + if (BirthDate == birthDate && + Gender == gender && + Bio == bio) + return this; + + BirthDate = birthDate; + Gender = gender; + Bio = bio; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateLocalization(string? language, string? timeZone, string? culture, DateTimeOffset now) + { + if (Language == language && + TimeZone == timeZone && + Culture == culture) + return this; + + Language = language; + TimeZone = timeZone; + Culture = culture; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateMetadata(IReadOnlyDictionary? metadata, DateTimeOffset now) + { + if (Metadata == metadata) + return this; + + Metadata = metadata; + UpdatedAt = now; + + return this; + } + + public UserProfile MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs new file mode 100644 index 00000000..c197d94f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserProfileKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs index 3044076e..f3ccc480 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -15,7 +15,7 @@ public UserProfileSnapshotProvider(IUserProfileStore store) public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) { - var profile = await _store.GetAsync(tenant, userKey, ct); + var profile = await _store.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 5d0a536c..81cea8c2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -17,19 +17,4 @@ public static UserViewDto ToDto(UserProfile profile) Gender = profile.Gender, Metadata = profile.Metadata }; - - public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) - => new() - { - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata - }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 4ef8b376..1fc8b37d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -62,7 +62,7 @@ public async Task CreateUserAsync(AccessContext context, Creat { var command = new CreateUserCommand(async innerCt => { - var validationResult = await _userCreateValidator.ValidateAsync(context, request, ct); + var validationResult = await _userCreateValidator.ValidateAsync(context, request, innerCt); if (validationResult.IsValid != true) { throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); @@ -71,98 +71,71 @@ public async Task CreateUserAsync(AccessContext context, Creat var now = _clock.UtcNow; var userKey = UserKey.New(); - if (string.IsNullOrWhiteSpace(request.UserName) && string.IsNullOrWhiteSpace(request.Email) && string.IsNullOrWhiteSpace(request.Phone)) - { - throw new UAuthValidationException("identifier_required"); - } - - await _lifecycleStore.CreateAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); - - if (!string.IsNullOrWhiteSpace(request.Password)) - { - var hash = _passwordHasher.Hash(request.Password); + await _lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); - await _credentialStore.AddAsync( + await _profileStore.AddAsync( + UserProfile.Create( + now, context.ResourceTenant, - PasswordCredential.Create(null, context.ResourceTenant, userKey, hash, CredentialSecurityState.Active(), new CredentialMetadata(), now), - innerCt); - } - - await _profileStore.CreateAsync(context.ResourceTenant, - new UserProfile - { - Tenant = context.ResourceTenant, - UserKey = userKey, - FirstName = request.FirstName, - LastName = request.LastName, - DisplayName = request.DisplayName ?? request.UserName ?? request.Email, - BirthDate = request.BirthDate, - Gender = request.Gender, - Bio = request.Bio, - Language = request.Language, - TimeZone = request.TimeZone, - Culture = request.Culture, - Metadata = request.Metadata, - CreatedAt = now - }, - innerCt); + userKey, + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture), innerCt); if (!string.IsNullOrWhiteSpace(request.UserName)) { - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - Tenant = context.ResourceTenant, - UserKey = userKey, - Type = UserIdentifierType.Username, - Value = request.UserName, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, - IsPrimary = true, - IsVerified = request.UserNameVerified, - CreatedAt = now, - VerifiedAt = request.UserNameVerified ? now : null - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Username, + request.UserName, + _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, + now, + true, + request.UserNameVerified ? now : null), innerCt); } if (!string.IsNullOrWhiteSpace(request.Email)) { - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - Tenant = context.ResourceTenant, - UserKey = userKey, - Type = UserIdentifierType.Email, - Value = request.Email, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, - IsPrimary = true, - IsVerified = request.EmailVerified, - CreatedAt = now, - VerifiedAt = request.EmailVerified ? now : null - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Email, + request.Email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, + now, + true, + request.EmailVerified ? now : null), innerCt); } if (!string.IsNullOrWhiteSpace(request.Phone)) { - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - Tenant = context.ResourceTenant, - UserKey = userKey, - Type = UserIdentifierType.Phone, - Value = request.Phone, - NormalizedValue = _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, - IsPrimary = true, - IsVerified = request.PhoneVerified, - CreatedAt = now, - VerifiedAt = request.PhoneVerified ? now : null - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Phone, + request.Phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + now, + true, + request.PhoneVerified ? now : null), innerCt); } foreach (var integration in _integrations) { + // Credential creation handle on here await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, innerCt); } @@ -186,7 +159,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C var targetUserKey = context.GetTargetUserKey(); var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); var current = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); - var now = DateTimeOffset.UtcNow; + var now = _clock.UtcNow; if (current is null) throw new InvalidOperationException("user_not_found"); @@ -200,7 +173,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C throw new InvalidOperationException("admin_cannot_set_self_status"); } var newEntity = current.ChangeStatus(now, newStatus); - await _lifecycleStore.UpdateAsync(newEntity, current.Version, innerCt); + await _lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -214,9 +187,20 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var now = _clock.UtcNow; var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); - await _lifecycleStore.DeleteAsync(userLifecycleKey, request.Mode, now, innerCt); + var lifecycle = await _lifecycleStore.GetAsync(userLifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + var profileKey = new UserProfileKey(context.ResourceTenant, targetUserKey); + var profile = await _profileStore.GetAsync(profileKey, innerCt); + await _lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); await _identifierStore.DeleteByUserAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); - await _profileStore.DeleteAsync(context.ResourceTenant, targetUserKey, request.Mode, now, innerCt); + + if (profile is not null) + { + await _profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + } foreach (var integration in _integrations) { @@ -263,10 +247,26 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq { var command = new UpdateUserProfileCommand(async innerCt => { - var targetUserKey = context.GetTargetUserKey(); - var update = UserProfileMapper.ToUpdate(request); + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var key = new UserProfileKey(tenant, userKey); - await _profileStore.UpdateAsync(context.ResourceTenant, targetUserKey, update, _clock.UtcNow, innerCt); + var profile = await _profileStore.GetAsync(key, innerCt); + + if (profile is null) + throw new UAuthNotFoundException(); + + var expectedVersion = profile.Version; + + profile + .UpdateName(request.FirstName, request.LastName, request.DisplayName, now) + .UpdatePersonalInfo(request.BirthDate, request.Gender, request.Bio, now) + .UpdateLocalization(request.Language, request.TimeZone, request.Culture, now) + .UpdateMetadata(request.Metadata, now); + + await _profileStore.SaveAsync(profile, expectedVersion, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -338,7 +338,7 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie var command = new AddUserIdentifierCommand(async innerCt => { var validationDto = new UserIdentifierDto() { Type = request.Type, Value = request.Value }; - var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, ct); + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); if (validationResult.IsValid != true) { throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); @@ -388,18 +388,16 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie EnsureVerificationRequirements(request.Type, isVerified: false); } - await _identifierStore.CreateAsync(context.ResourceTenant, - new UserIdentifier - { - UserKey = userKey, - Type = request.Type, - Value = request.Value, - NormalizedValue = normalized.Normalized, - IsPrimary = request.IsPrimary, - IsVerified = false, - CreatedAt = _clock.UtcNow - }, - innerCt); + await _identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + request.Type, + request.Value, + normalized.Normalized, + _clock.UtcNow, + request.IsPrimary), innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -422,7 +420,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde } var validationDto = identifier.ToDto(); - var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, ct); + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); if (validationResult.IsValid != true) { throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); @@ -596,7 +594,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (request.Mode == DeleteMode.Hard) { - await _identifierStore.DeleteAsync(identifier, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); + await _identifierStore.DeleteAsync(identifier.Id, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); } else { @@ -615,7 +613,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { - var profile = await _profileStore.GetAsync(tenant, userKey, ct); + var profile = await _profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); if (profile is null || profile.IsDeleted) throw new InvalidOperationException("user_profile_not_found"); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 58d18214..2560e51b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -1,11 +1,12 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserIdentifierStore +public interface IUserIdentifierStore : IVersionedStore { Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); @@ -14,11 +15,6 @@ public interface IUserIdentifierStore Task GetAsync(TenantKey tenant, UserIdentifierType type, string value, CancellationToken ct = default); Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); - Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserIdentifier identifier, CancellationToken ct = default); - - Task DeleteAsync(UserIdentifier entity, long expectedVersion, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 8c34959d..c8151c27 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,20 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; -public interface IUserProfileStore +public interface IUserProfileStore : IVersionedStore { - Task ExistsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); - Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); - - Task CreateAsync(TenantKey tenant, UserProfile profile, CancellationToken ct = default); - - Task UpdateAsync(TenantKey tenant, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); - - Task DeleteAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } From 25bf2bf342c3c35ebed2fbb5791f99a1a04804c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 8 Mar 2026 15:41:59 +0300 Subject: [PATCH 18/29] Authorization Completion & Tests --- .../Stores/InMemoryVersionedStore.cs | 9 ++ .../Constants/UAuthConstants.cs | 11 ++ .../Contracts/Access/AccessScope.cs | 9 ++ .../Contracts/Authority/AccessContext.cs | 2 +- .../Defaults/UAuthActions.cs | 19 ++- .../Orchestrator/UAuthAccessOrchestrator.cs | 5 +- .../Infrastructure/CompiledPermissionSet.cs | 47 ++++++ .../Services/AuthorizationService.cs | 41 +++--- .../AuthorizationClaimsProvider.cs | 5 +- .../Defaults/DefaultPolicySet.cs | 2 - .../Fluent/ConditionalScopeBuilder.cs | 6 +- .../Fluent/IPolicyScopeBuilder.cs | 3 +- .../Fluent/PolicyScopeBuilder.cs | 6 +- .../Policies/MustHavePermissionPolicy.cs | 12 +- .../Policies/RequireAdminPolicy.cs | 25 ---- .../Policies/RequireSelfOrAdminPolicy.cs | 35 ----- .../Stores/InMemoryUserIdentifierStore.cs | 19 ++- .../CompiledPermissionSetTests.cs | 113 +++++++++++++++ .../Authorization/UAuthActionsTests.cs | 47 ++++++ .../Policies/ActionTextTests.cs | 52 +++++-- .../Users/IdentifierConcurrencyTests.cs | 134 +++--------------- 21 files changed, 370 insertions(+), 232 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs create mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs delete mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs delete mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs index efdaf506..ee9d9df3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -105,7 +105,16 @@ public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTim return Task.CompletedTask; } + /// + /// Returns a read-only list containing the current snapshot of all entities stored in memory. + /// protected IReadOnlyList Values() => _store.Values.Select(Snapshot).ToList().AsReadOnly(); + /// + /// Returns an enumerable collection of all entities currently stored in memory. + /// Useful in hooks when change entity value directly. + /// + protected IEnumerable InternalValues() => _store.Values; + protected bool TryGet(TKey key, out TEntity? entity) => _store.TryGetValue(key, out entity); } diff --git a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs index e7bb1179..9906aec8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs @@ -2,6 +2,17 @@ public static class UAuthConstants { + public static class Access + { + public const string Permissions = "permissions"; + } + + public static class Claims + { + public const string Tenant = "uauth:tenant"; + public const string Permission = "uauth:permission"; + } + public static class HttpItems { public const string SessionContext = "__UAuth.SessionContext"; diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs new file mode 100644 index 00000000..0a881359 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum ActionScope +{ + Anonymous, + Self, + Admin, + System +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 67077045..e526683e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -40,7 +40,7 @@ internal AccessContext( bool isAuthenticated, bool isSystemActor, SessionChainId? actorChainId, - string resource, + string? resource, UserKey? targetUserKey, TenantKey resourceTenant, string action, diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 3be8ffa5..51d2d5b9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -1,7 +1,24 @@ -namespace CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthActions { + public static string Create(string resource, string operation, ActionScope scope, string? subResource = null) + { + if (string.IsNullOrWhiteSpace(resource)) + throw new ArgumentException("resource required"); + + if (string.IsNullOrWhiteSpace(operation)) + throw new ArgumentException("operation required"); + + var scopePart = scope.ToString().ToLowerInvariant(); + + return subResource is null + ? $"{resource}.{operation}.{scopePart}" + : $"{resource}.{subResource}.{operation}.{scopePart}"; + } + public static class Sessions { public const string GetChainSelf = "sessions.getchain.self"; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 496c3e70..26b1e085 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Policies.Abstractions; @@ -61,6 +63,7 @@ private async Task EnrichAsync(AccessContext context, Cancellatio return context; var perms = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); - return context.WithAttribute("permissions", perms); + var compiled = new CompiledPermissionSet(perms); + return context.WithAttribute(UAuthConstants.Access.Permissions, compiled); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs new file mode 100644 index 00000000..e1214ce1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs @@ -0,0 +1,47 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class CompiledPermissionSet +{ + private readonly HashSet _exact = new(); + private readonly HashSet _prefix = new(); + private readonly bool _hasWildcard; + + public CompiledPermissionSet(IEnumerable permissions) + { + foreach (var p in permissions) + { + var value = p.Value; + + if (value == "*") + { + _hasWildcard = true; + continue; + } + + if (value.EndsWith(".*")) + { + _prefix.Add(value[..^2]); + continue; + } + + _exact.Add(value); + } + } + + public bool IsAllowed(string action) + { + if (_hasWildcard) + return true; + + if (_exact.Contains(action)) + return true; + + foreach (var prefix in _prefix) + { + if (action.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs index 2379e284..720bd196 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs @@ -1,45 +1,36 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; -using CodeBeam.UltimateAuth.Authorization.Domain; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Authorization.Reference; internal sealed class AuthorizationService : IAuthorizationService { - private readonly IUserPermissionStore _permissions; - private readonly IAccessPolicyProvider _policyProvider; - private readonly IAccessAuthority _accessAuthority; + private readonly IAccessOrchestrator _accessOrchestrator; - public AuthorizationService(IUserPermissionStore permissions, IAccessPolicyProvider policyProvider, IAccessAuthority accessAuthority) + public AuthorizationService(IAccessOrchestrator accessOrchestrator) { - _permissions = permissions; - _policyProvider = policyProvider; - _accessAuthority = accessAuthority; + _accessOrchestrator = accessOrchestrator; } public async Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - IReadOnlyCollection permissions = Array.Empty(); + var cmd = new AccessCommand(innerCt => + { + return Task.FromResult(AuthorizationResult.Allow()); + }); - if (context.ActorUserKey is not null) + try { - permissions = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return AuthorizationResult.Allow(); + } + catch (UAuthAuthorizationException ex) + { + return AuthorizationResult.Deny(ex.Message); } - - var enrichedContext = context.WithAttribute("permissions", permissions); - - var policies = _policyProvider.GetPolicies(enrichedContext); - var decision = _accessAuthority.Decide(enrichedContext, policies); - - if (decision.RequiresReauthentication) - return AuthorizationResult.ReauthRequired(); - - return decision.IsAllowed - ? AuthorizationResult.Allow() - : AuthorizationResult.Deny(decision.DenyReason); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs index 67c3c457..dd6fbe24 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/AuthorizationClaimsProvider.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; @@ -26,13 +27,13 @@ public async Task GetClaimsAsync(TenantKey tenant, UserKey userK var builder = ClaimsSnapshot.Create(); - builder.Add("uauth:tenant", tenant.Value); + builder.Add(UAuthConstants.Claims.Tenant, tenant.Value); foreach (var role in roles) builder.Add(ClaimTypes.Role, role.Name); foreach (var perm in perms) - builder.Add("uauth:permission", perm.Value); + builder.Add(UAuthConstants.Claims.Permission, perm.Value); return builder.Build(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index 45fc9fd8..fdf4e582 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -16,8 +16,6 @@ public static void Register(AccessPolicyRegistry registry) // Intent-based registry.Add("", _ => new RequireSelfPolicy()); - registry.Add("", _ => new RequireAdminPolicy()); - registry.Add("", _ => new RequireSelfOrAdminPolicy()); registry.Add("", _ => new RequireSystemPolicy()); // Permission diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs index 594f2723..4c6181ae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -29,8 +30,7 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy } public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs index 1916eeee..9f400fae 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -4,7 +4,6 @@ public interface IPolicyScopeBuilder { IPolicyScopeBuilder RequireAuthenticated(); IPolicyScopeBuilder RequireSelf(); - IPolicyScopeBuilder RequireAdmin(); - IPolicyScopeBuilder RequireSelfOrAdmin(); + IPolicyScopeBuilder RequirePermission(); IPolicyScopeBuilder DenyCrossTenant(); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs index 61adab98..134ba717 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Policies.Registry; using Microsoft.Extensions.DependencyInjection; @@ -20,8 +21,7 @@ public PolicyScopeBuilder(string prefix, AccessPolicyRegistry registry, IService public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder RequireSelf() => Add(); - public IPolicyScopeBuilder RequireAdmin() => Add(); - public IPolicyScopeBuilder RequireSelfOrAdmin() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs index 573b4744..59a01b92 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Authorization.Policies; @@ -8,15 +9,10 @@ public sealed class MustHavePermissionPolicy : IAccessPolicy { public AccessDecision Decide(AccessContext context) { - if (context.Attributes.TryGetValue("permissions", out var value) && value is IReadOnlyCollection permissions) + if (context.Attributes.TryGetValue(UAuthConstants.Access.Permissions, out var value) && value is CompiledPermissionSet permissions) { - var actionPermission = Permission.From(context.Action); - - if (permissions.Contains(actionPermission) || - permissions.Contains(Permission.Wildcard)) - { + if (permissions.IsAllowed(context.Action)) return AccessDecision.Allow(); - } } return AccessDecision.Deny("missing_permission"); @@ -24,6 +20,6 @@ public AccessDecision Decide(AccessContext context) public bool AppliesTo(AccessContext context) { - return context.Action.EndsWith(".admin"); + return context.Action.EndsWith(".admin", StringComparison.OrdinalIgnoreCase); } } \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs deleted file mode 100644 index 97702678..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (!context.Attributes.TryGetValue("roles", out var value)) - return AccessDecision.Deny("missing_roles"); - - if (value is not IReadOnlyCollection roles) - return AccessDecision.Deny("invalid_roles"); - - return roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) - ? AccessDecision.Allow() - : AccessDecision.Deny("admin_required"); - } - - public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".admin"); -} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs deleted file mode 100644 index 40be093d..00000000 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Policies; - -internal sealed class RequireSelfOrAdminPolicy : IAccessPolicy -{ - public AccessDecision Decide(AccessContext context) - { - if (!context.IsAuthenticated) - return AccessDecision.Deny("unauthenticated"); - - if (context.IsSelfAction) - return AccessDecision.Allow(); - - if (context.Attributes.TryGetValue("roles", out var value) - && value is IReadOnlyCollection roles - && roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) - { - return AccessDecision.Allow(); - } - - return AccessDecision.Deny("self_or_admin_required"); - } - - public bool AppliesTo(AccessContext context) - { - if (context.Action.EndsWith(".anonymous")) - { - return false; - } - - return !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index af8fbcb8..2c6f382d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -83,12 +83,29 @@ public Task> GetByUserAsync(TenantKey tenant, User return Task.FromResult>(result); } + protected override void BeforeAdd(UserIdentifier entity) + { + if (!entity.IsPrimary) + return; + + foreach (var other in InternalValues().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } + } + protected override void BeforeSave(UserIdentifier entity, UserIdentifier current, long expectedVersion) { if (!entity.IsPrimary) return; - foreach (var other in Values().Where(x => + foreach (var other in InternalValues().Where(x => x.Tenant == entity.Tenant && x.UserKey == entity.UserKey && x.Type == entity.Type && diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs new file mode 100644 index 00000000..74578556 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs @@ -0,0 +1,113 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CompiledPermissionSetTests +{ + [Fact] + public void ExactPermission_Allows_Action() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void ExactPermission_DoesNotAllow_OtherAction() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.create.admin")); + } + + [Fact] + public void WildcardPermission_AllowsEverything() + { + var permissions = new[] + { + Permission.Wildcard + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("anything.really.admin")); + } + + [Fact] + public void PrefixPermission_AllowsChildren() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("users.profile.update.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotAllowOtherResource() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("sessions.revoke.admin")); + } + + [Fact] + public void NestedPrefixPermission_Works() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.profile.update.admin")); + Assert.True(set.IsAllowed("users.profile.get.self")); + } + + [Fact] + public void NestedPrefixPermission_DoesNotMatchParent() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotMatchSimilarPrefix() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("usersettings.update.admin")); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs new file mode 100644 index 00000000..45df809b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Actions; + +public class UAuthActionsTests +{ + [Fact] + public void Create_WithResourceOperationScope_Works() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_WithSubResource_Works() + { + var action = UAuthActions.Create("users", "update", ActionScope.Self, "profile"); + Assert.Equal("users.profile.update.self", action); + } + + [Fact] + public void Create_ProducesLowercaseScope() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_DifferentScopes_Work() + { + var self = UAuthActions.Create("users", "update", ActionScope.Self); + var admin = UAuthActions.Create("users", "delete", ActionScope.Admin); + var anon = UAuthActions.Create("users", "create", ActionScope.Anonymous); + + Assert.Equal("users.update.self", self); + Assert.Equal("users.delete.admin", admin); + Assert.Equal("users.create.anonymous", anon); + } + + [Fact] + public void Create_DoesNotCreateDoubleDots_WhenSubResourceNull() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.DoesNotContain("..", action); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs index bb7e05d6..b64b32db 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -1,26 +1,60 @@ -using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Authorization.Policies; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Unit; public class ActionTextTests { + private readonly MustHavePermissionPolicy _policy = new(); + [Theory] [InlineData("users.profile.get.admin", true)] - [InlineData("users.profile.get.self", false)] - [InlineData("users.profile.get", false)] - public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) + [InlineData("users.delete.admin", true)] + [InlineData("sessions.revoke.admin", true)] + public void AppliesTo_ReturnsTrue_ForAdminScope(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("users.profile.get.self")] + [InlineData("users.profile.get")] + [InlineData("users.profile.get.anonymous")] + public void AppliesTo_ReturnsFalse_ForNonAdminScope(string action) { var context = TestAccessContext.WithAction(action); - var policy = new RequireAdminPolicy(); - Assert.Equal(expected, policy.AppliesTo(context)); + var result = _policy.AppliesTo(context); + Assert.False(result); } [Fact] - public void RequireAdminPolicy_DoesNotMatch_Substrings() + public void AppliesTo_DoesNotMatch_Substrings() { var context = TestAccessContext.WithAction("users.profile.get.administrator"); - var policy = new RequireAdminPolicy(); - Assert.False(policy.AppliesTo(context)); + var result = _policy.AppliesTo(context); + Assert.False(result); + } + + [Fact] + public void AppliesTo_IsCaseInsensitive() + { + var context = TestAccessContext.WithAction("users.profile.get.ADMIN"); + var result = _policy.AppliesTo(context); + Assert.True(result); + } + + [Theory] + [InlineData("users.create.admin", true)] + [InlineData("users.create.self", false)] + [InlineData("users.create.anonymous", false)] + [InlineData("users.create", false)] + [InlineData("users.create.admin.extra", false)] + public void AppliesTo_AdminScopeDetection(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 7a0b7a9c..a66d3dd7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -17,18 +17,8 @@ public async Task Save_should_increment_version() var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = TenantKey.Single, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - await store.CreateAsync(TenantKey.Single, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); var copy = await store.GetByIdAsync(id); var expected = copy!.Version; @@ -48,18 +38,8 @@ public async Task Delete_should_throw_when_version_conflicts() var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = TenantKey.Single, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - await store.CreateAsync(TenantKey.Single, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); var copy1 = await store.GetByIdAsync(id); var copy2 = await store.GetByIdAsync(id); @@ -70,7 +50,7 @@ public async Task Delete_should_throw_when_version_conflicts() await Assert.ThrowsAsync(async () => { - await store.DeleteAsync(copy2!, copy2!.Version, DeleteMode.Soft, now); + await store.DeleteAsync(copy2!.Id, copy2!.Version, DeleteMode.Soft, now); }); } @@ -81,18 +61,8 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = TenantKey.Single, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - await store.CreateAsync(TenantKey.Single, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); int success = 0; int conflicts = 0; @@ -138,18 +108,8 @@ public async Task Update_should_throw_concurrency_when_versions_conflict() var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; - var identifier = new UserIdentifier - { - Id = id, - Tenant = tenant, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - await store.CreateAsync(tenant, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); var copy1 = await store.GetByIdAsync(id); var copy2 = await store.GetByIdAsync(id); @@ -175,18 +135,8 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic var tenant = TenantKey.Single; var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = tenant, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - await store.CreateAsync(tenant, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); int success = 0; int conflicts = 0; @@ -199,10 +149,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic try { var copy = await store.GetByIdAsync(id); - - // İki thread de burada bekler barrier.SignalAndWait(); - var expected = copy!.Version; var newValue = i == 0 @@ -210,9 +157,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic : "y@test.com"; copy.ChangeValue(newValue, newValue, now); - await store.SaveAsync(copy, expected); - Interlocked.Increment(ref success); } catch (UAuthConcurrencyException) @@ -241,18 +186,8 @@ public async Task High_contention_updates_should_allow_only_one_success() var tenant = TenantKey.Single; var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = tenant, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "initial@test.com", - NormalizedValue = "initial@test.com", - CreatedAt = now - }; - - await store.CreateAsync(tenant, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "initial@test.com", "initial@test.com", now); + await store.AddAsync(identifier); int success = 0; int conflicts = 0; @@ -298,18 +233,8 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic var id = Guid.NewGuid(); - var identifier = new UserIdentifier - { - Id = id, - Tenant = tenant, - UserKey = TestUsers.Admin, - Type = UserIdentifierType.Email, - Value = "primary@test.com", - NormalizedValue = "primary@test.com", - CreatedAt = now - }; - - await store.CreateAsync(tenant, identifier); + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "primary@test.com", "primary@test.com", now); + await store.AddAsync(identifier); int success = 0; int conflicts = 0; @@ -359,30 +284,11 @@ public async Task Two_identifiers_racing_for_primary_should_allow() var id1 = Guid.NewGuid(); var id2 = Guid.NewGuid(); - var identifier1 = new UserIdentifier - { - Id = id1, - Tenant = tenant, - UserKey = user, - Type = UserIdentifierType.Email, - Value = "a@test.com", - NormalizedValue = "a@test.com", - CreatedAt = now - }; - - var identifier2 = new UserIdentifier - { - Id = id2, - Tenant = tenant, - UserKey = user, - Type = UserIdentifierType.Email, - Value = "b@test.com", - NormalizedValue = "b@test.com", - CreatedAt = now - }; - - await store.CreateAsync(tenant, identifier1); - await store.CreateAsync(tenant, identifier2); + var identifier1 = UserIdentifier.Create(id1, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + var identifier2 = UserIdentifier.Create(id2, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "b@test.com", "b@test.com", now); + + await store.AddAsync(identifier1); + await store.AddAsync(identifier2); int success = 0; int conflicts = 0; From 54016f7d2fc29f5441d486b63f85bf701202c283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 8 Mar 2026 15:58:38 +0300 Subject: [PATCH 19/29] Fixed Identifier Concurrency Test --- .../Stores/InMemoryVersionedStore.cs | 4 ++-- .../Stores/InMemoryUserIdentifierStore.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs index ee9d9df3..db4d54f6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/InMemoryVersionedStore.cs @@ -33,7 +33,7 @@ public Task ExistsAsync(TKey key, CancellationToken ct = default) return Task.FromResult(_store.ContainsKey(key)); } - public Task AddAsync(TEntity entity, CancellationToken ct = default) + public virtual Task AddAsync(TEntity entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -48,7 +48,7 @@ public Task AddAsync(TEntity entity, CancellationToken ct = default) return Task.CompletedTask; } - public Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) + public virtual Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 2c6f382d..d898c5de 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -11,6 +11,7 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserIdentifierStore : InMemoryVersionedStore, IUserIdentifierStore { protected override Guid GetKey(UserIdentifier entity) => entity.Id; + private readonly object _primaryLock = new(); public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { @@ -100,6 +101,14 @@ protected override void BeforeAdd(UserIdentifier entity) } } + public override Task AddAsync(UserIdentifier entity, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.AddAsync(entity, ct); + } + } + protected override void BeforeSave(UserIdentifier entity, UserIdentifier current, long expectedVersion) { if (!entity.IsPrimary) @@ -117,6 +126,14 @@ protected override void BeforeSave(UserIdentifier entity, UserIdentifier current } } + public override Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.SaveAsync(entity, expectedVersion, ct); + } + } + public Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); From 032a0a5319e05fae59e5d3f23475b6180236cbc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 9 Mar 2026 00:02:43 +0300 Subject: [PATCH 20/29] Profile Dialog & UAuthClientEvents --- .../Components/Dialogs/CredentialDialog.razor | 3 +- .../Components/Dialogs/ProfileDialog.razor | 174 ++++++++++++++++++ .../Components/Pages/Home.razor | 8 +- .../Components/Pages/Home.razor.cs | 5 + .../Program.cs | 1 + .../Abstractions/ISessionEvents.cs | 12 -- .../IUAuthStateManager.cs | 0 .../UAuthAuthenticationStateProvider.cs | 0 .../UAuthCascadingStateProvider.cs | 0 .../UAuthState.cs | 15 ++ .../UAuthStateChangeReason.cs | 3 +- .../AuthState/UAuthStateEvent.cs | 12 ++ .../AuthState/UAuthStateEventArgs.cs | 16 ++ .../AuthState/UAuthStateManager.cs | 148 +++++++++++++++ .../AuthState/UAuthStateRefreshMode.cs | 8 + .../Authentication/UAuthStateManager.cs | 79 -------- .../Events/IUAuthClientEvents.cs | 8 + .../Events/UAuthClientEvents.cs | 21 +++ .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Infrastructure/SessionEvents.cs | 13 -- .../Options/UAuthClientOptions.cs | 1 + .../Services/UAuthCredentialClient.cs | 20 +- .../Services/UAuthFlowClient.cs | 9 +- .../Services/UAuthSessionClient.cs | 10 +- .../Services/UAuthUserClient.cs | 15 +- .../Services/UAuthUserIdentifierClient.cs | 31 +++- .../Abstractions/Stores/ISessionStore.cs | 43 +++-- .../Services/CredentialManagementService.cs | 15 +- .../Stores/EfCoreSessionStore.cs | 140 +++++++++++--- .../InMemorySessionStore.cs | 137 +++++++++++--- .../Dtos/UserViewDto.cs | 3 + .../Mapping/UserProfileMapper.cs | 3 + .../Services/UserApplicationService.cs | 8 - .../Client/UAuthStateManagerTests.cs | 42 +++-- 34 files changed, 785 insertions(+), 221 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor delete mode 100644 src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/IUAuthStateManager.cs (100%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthAuthenticationStateProvider.cs (100%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthCascadingStateProvider.cs (100%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthState.cs (87%) rename src/CodeBeam.UltimateAuth.Client/{Authentication => AuthState}/UAuthStateChangeReason.cs (84%) create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateRefreshMode.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs create mode 100644 src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs delete mode 100644 src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor index 3d43db85..47436c7e 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -92,7 +92,8 @@ if (result.IsSuccess) { Snackbar.Add("Password changed successfully", Severity.Success); - await UAuthClient.Flows.LogoutAsync(); + // Logout also if you want. Password change only revoke other open sessions, not the current one. + // await UAuthClient.Flows.LogoutAsync(); } else { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor new file mode 100644 index 00000000..a6c7df97 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,174 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + + + + Name + + + + + + + + + + + + + + + + + + + Personal + + + + + + + + + + + + + + + + + + + Localization + + + + + + + + + + + @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + @tz.Id - @tz.DisplayName + } + + + + + + + + + + + + Cancel + Save + + + +@code { + + private MudForm? _form; + + private string? _firstName; + private string? _lastName; + private string? _displayName; + + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + + private string? _language; + private string? _timeZone; + private string? _culture; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var result = await UAuthClient.Users.GetMeAsync(); + + if (result.IsSuccess && result.Value is not null) + { + var p = result.Value; + + _firstName = p.FirstName; + _lastName = p.LastName; + _displayName = p.DisplayName; + + _gender = p.Gender; + _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); + _bio = p.Bio; + + _language = p.Language; + _timeZone = p.TimeZone; + _culture = p.Culture; + } + } + + private async Task SaveAsync() + { + if (AuthState is null || AuthState.Identity is null) + { + Snackbar.Add("No AuthState found.", Severity.Error); + return; + } + + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + return; + } + + var request = new UpdateProfileRequest + { + FirstName = _firstName, + LastName = _lastName, + DisplayName = _displayName, + BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, + Gender = _gender, + Bio = _bio, + Language = _language, + TimeZone = _timeZone, + Culture = _culture + }; + + var result = await UAuthClient.Users.UpdateMeAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index d81129a8..6f9c2b8f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -16,7 +16,9 @@ - @(AuthState?.Identity?.DisplayName?.Substring(0, 2)) + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + @AuthState?.Identity?.DisplayName @@ -223,6 +225,10 @@ Manage Sessions + + Manage Profile + + Manage Identifiers diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 704a53fc..a9fe309a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -151,6 +151,11 @@ private string GetHealthText() return utc?.ToLocalTime().ToString("dd MMM yyyy • HH:mm:ss"); } + private async Task OpenProfileDialog() + { + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), GetDialogOptions()); + } + private async Task OpenIdentifierDialog() { await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), GetDialogOptions()); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 1c56aaf5..e1f5c19f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -65,6 +65,7 @@ { //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs b/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs deleted file mode 100644 index 3c58ad56..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Abstractions/ISessionEvents.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Client.Abstractions; - -public interface ISessionEvents -{ - /// - /// Fired when the current session becomes invalid - /// due to revoke or security mismatch. - /// - event Action? CurrentSessionRevoked; - - void RaiseCurrentSessionRevoked(); -} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/IUAuthStateManager.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthAuthenticationStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthAuthenticationStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthCascadingStateProvider.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthCascadingStateProvider.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs similarity index 87% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index 7b106dd0..dd71847d 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Client; @@ -43,6 +44,20 @@ internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validated Changed?.Invoke(UAuthStateChangeReason.Authenticated); } + // TODO: Improve patch semantics with identifier, profile add, update or delete. + internal void UpdateProfile(UpdateProfileRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + DisplayName = req.DisplayName ?? Identity.DisplayName + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + internal void MarkValidated(DateTimeOffset now) { if (!IsAuthenticated) diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs rename to src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs index 587115bf..2d9602b0 100644 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateChangeReason.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs @@ -5,5 +5,6 @@ public enum UAuthStateChangeReason Authenticated, Validated, MarkedStale, - Cleared + Cleared, + Patched } diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs new file mode 100644 index 00000000..82f317fc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEvent +{ + ValidationCalled, + IdentifiersChanged, + ProfileChanged, + CredentialsChanged, + RolesChanged, + PermissionsChanged, + SessionRevoked +} diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs new file mode 100644 index 00000000..25558c8e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Client; + +public abstract record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateRefreshMode RefreshMode); + +public sealed record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateRefreshMode RefreshMode, + TPayload Payload) + : UAuthStateEventArgs(Type, RefreshMode); + +public sealed record UAuthStateEventArgsEmpty( + UAuthStateEvent Type, + UAuthStateRefreshMode RefreshMode) + : UAuthStateEventArgs(Type, RefreshMode); diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs new file mode 100644 index 00000000..f98305e0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -0,0 +1,148 @@ +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Authentication; + +internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable +{ + private Task? _ensureTask; + private readonly object _ensureLock = new(); + + private readonly IUAuthClient _client; + private readonly IUAuthClientEvents _events; + private readonly IClock _clock; + + public UAuthState State { get; } = UAuthState.Anonymous(); + + public UAuthStateManager(IUAuthClient client, IUAuthClientEvents events, IClock clock) + { + _client = client; + _events = events; + _clock = clock; + + _events.StateChanged += HandleStateEvent; + } + + public Task EnsureAsync(bool force = false, CancellationToken ct = default) + { + if (!force && State.IsAuthenticated && !State.IsStale) + return Task.CompletedTask; + + lock (_ensureLock) + { + if (_ensureTask != null) + return _ensureTask; + + _ensureTask = EnsureInternalAsync(ct); + return _ensureTask; + } + } + + private async Task EnsureInternalAsync(CancellationToken ct) + { + try + { + var result = await _client.Flows.ValidateAsync(); + + if (!result.IsValid || result.Snapshot == null) + { + if (State.IsAuthenticated) + State.MarkStale(); + else + State.Clear(); + + return; + } + + State.ApplySnapshot(result.Snapshot, _clock.UtcNow); + } + finally + { + lock (_ensureLock) + { + _ensureTask = null; + } + } + } + + private async Task HandleStateEvent(UAuthStateEventArgs args) + { + if (args.Type == UAuthStateEvent.SessionRevoked) + { + if (State.IsAuthenticated) + { + State.Clear(); + return; + } + + State.MarkStale(); + return; + } + + if (args.Type == UAuthStateEvent.CredentialsChanged) + { + State.Clear(); + return; + } + + switch (args) + { + case UAuthStateEventArgs profile: + State.UpdateProfile(profile.Payload); + break; + } + + switch (args.RefreshMode) + { + case UAuthStateRefreshMode.Validate: + await EnsureAsync(true); + break; + + case UAuthStateRefreshMode.Patch: + if (args.Type == UAuthStateEvent.ValidationCalled) + { + State.MarkValidated(_clock.UtcNow); + } + if (args.Type == UAuthStateEvent.IdentifiersChanged) + { + + } + // optional patch logic + break; + + case UAuthStateRefreshMode.None: + default: + break; + } + } + + public Task OnLoginAsync() + { + State.MarkStale(); + return Task.CompletedTask; + } + + public Task OnLogoutAsync() + { + State.Clear(); + return Task.CompletedTask; + } + + public void MarkStale() + { + State.MarkStale(); + } + + public void Clear() + { + State.Clear(); + } + + public void Dispose() + { + _events.StateChanged -= HandleStateEvent; + } + + public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; +} diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateRefreshMode.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateRefreshMode.cs new file mode 100644 index 00000000..876c5dc1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateRefreshMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateRefreshMode +{ + None, + Patch, + Validate +} diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs deleted file mode 100644 index 9a8195bb..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/UAuthStateManager.cs +++ /dev/null @@ -1,79 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; -using CodeBeam.UltimateAuth.Core.Abstractions; - -namespace CodeBeam.UltimateAuth.Client.Authentication; - -internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable -{ - private readonly IUAuthClient _client; - private readonly ISessionEvents _events; - private readonly IClock _clock; - - public UAuthState State { get; } = UAuthState.Anonymous(); - - public UAuthStateManager(IUAuthClient client, ISessionEvents events, IClock clock) - { - _client = client; - _events = events; - _clock = clock; - - _events.CurrentSessionRevoked += OnCurrentSessionRevoked; - } - - public async Task EnsureAsync(bool force = false, CancellationToken ct = default) - { - if (!force && State.IsAuthenticated && !State.IsStale) - return; - - var result = await _client.Flows.ValidateAsync(); - - if (!result.IsValid || result.Snapshot == null) - { - if (State.IsAuthenticated) - { - State.MarkStale(); - return; - } - - State.Clear(); - return; - } - - State.ApplySnapshot(result.Snapshot, _clock.UtcNow); - } - - public Task OnLoginAsync() - { - State.MarkStale(); - return Task.CompletedTask; - } - - public Task OnLogoutAsync() - { - State.Clear(); - return Task.CompletedTask; - } - - public void MarkStale() - { - State.MarkStale(); - } - - public void Clear() - { - State.Clear(); - } - - private void OnCurrentSessionRevoked() - { - if (State.IsAuthenticated) - Clear(); - } - - public void Dispose() - { - _events.CurrentSessionRevoked -= OnCurrentSessionRevoked; - } - - public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; -} diff --git a/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs new file mode 100644 index 00000000..dfe52ad2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Client.Events; + +public interface IUAuthClientEvents +{ + event Func? StateChanged; + Task PublishAsync(UAuthStateEventArgs args); + Task PublishAsync(UAuthStateEventArgs args); +} diff --git a/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs b/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs new file mode 100644 index 00000000..395126c3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Client.Events; + +internal sealed class UAuthClientEvents : IUAuthClientEvents +{ + public event Func? StateChanged; + + public Task PublishAsync(UAuthStateEventArgs args) + => PublishAsync((UAuthStateEventArgs)args); + + public async Task PublishAsync(UAuthStateEventArgs args) + { + var handlers = StateChanged; + if (handlers == null) + return; + + foreach (var handler in handlers.GetInvocationList()) + { + await ((Func)handler)(args); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs index ff12fac5..9223b29b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Client.Device; using CodeBeam.UltimateAuth.Client.Devices; using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Runtime; @@ -66,7 +67,7 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol services.AddSingleton(); services.AddSingleton, UAuthClientOptionsPostConfigure>(); services.TryAddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.PostConfigure(o => { diff --git a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs b/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs deleted file mode 100644 index 1494b29e..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Infrastructure/SessionEvents.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; - -namespace CodeBeam.UltimateAuth.Client.Infrastructure; - -internal sealed class SessionEvents : ISessionEvents -{ - public event Action? CurrentSessionRevoked; - - public void RaiseCurrentSessionRevoked() - { - CurrentSessionRevoked?.Invoke(); - } -} diff --git a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs index dab4589d..178a5be4 100644 --- a/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -6,6 +6,7 @@ public sealed class UAuthClientOptions { public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; public bool AutoDetectClientProfile { get; set; } = true; + public UAuthStateRefreshMode UAuthStateRefreshMode { get; set; } = UAuthStateRefreshMode.Patch; /// /// Global fallback return URL used by interactive authentication flows diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index b53fc6b2..262662c3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -1,8 +1,10 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Services; @@ -10,11 +12,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal sealed class UAuthCredentialClient : ICredentialClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthCredentialClient(IUAuthRequestClient request, IOptions options) + public UAuthCredentialClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -35,12 +39,20 @@ public async Task> AddMyAsync(AddCredentialRequ public async Task> ChangeMyAsync(ChangeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/change"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.FromJson(raw); } public async Task RevokeMyAsync(RevokeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/revoke"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.From(raw); } @@ -53,6 +65,10 @@ public async Task> BeginResetMyAsync(Beg public async Task> CompleteResetMyAsync(CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/credentials/reset/complete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.FromJson(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 3c81d96e..287ef712 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; @@ -19,13 +20,15 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal class UAuthFlowClient : IFlowClient { private readonly IUAuthRequestClient _post; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; private readonly NavigationManager _nav; - public UAuthFlowClient(IUAuthRequestClient post, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IOptions options, UAuthClientDiagnostics diagnostics, NavigationManager nav) { _post = post; + _events = events; _options = options.Value; _diagnostics = diagnostics; _nav = nav; @@ -149,7 +152,11 @@ public async Task ValidateAsync() throw new UAuthProtocolException("Malformed validation response."); if (raw.Status == 401 || (raw.Status >= 200 && raw.Status < 300)) + { + // Don't set refresh mode to validate here, it's already validated. + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.ValidationCalled, UAuthStateRefreshMode.Patch)); return body; + } if (raw.Status >= 400 && raw.Status < 500) throw new UAuthProtocolException($"Unexpected client error during validation: {raw.Status}"); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index d6e2b0ab..4a3a74fe 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; @@ -11,9 +11,9 @@ internal sealed class UAuthSessionClient : ISessionClient { private readonly IUAuthRequestClient _request; private readonly UAuthClientOptions _options; - private readonly ISessionEvents _events; + private readonly IUAuthClientEvents _events; - public UAuthSessionClient(IUAuthRequestClient request, IOptions options, ISessionEvents events) + public UAuthSessionClient(IUAuthRequestClient request, IOptions options, IUAuthClientEvents events) { _request = request; _options = options.Value; @@ -44,7 +44,7 @@ public async Task> RevokeMyChainAsync(SessionChainId c if (result.Value?.CurrentSessionRevoked == true) { - _events.RaiseCurrentSessionRevoked(); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.UAuthStateRefreshMode)); } return result; @@ -63,7 +63,7 @@ public async Task RevokeAllMyChainsAsync() if (result.IsSuccess) { - _events.RaiseCurrentSessionRevoked(); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.UAuthStateRefreshMode)); } return result; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 3f2c426b..021ee7b3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,11 +11,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal sealed class UAuthUserClient : IUserClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthUserClient(IUAuthRequestClient request, IOptions options) + public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -29,6 +32,10 @@ public async Task> GetMeAsync() public async Task UpdateMeAsync(UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.UAuthStateRefreshMode, request)); + } return UAuthResultMapper.From(raw); } @@ -41,6 +48,10 @@ public async Task> CreateAsync(CreateUserRequest r public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/status"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.UAuthStateRefreshMode, request)); + } return UAuthResultMapper.FromJson(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index cd5d1098..6a4f30cd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; @@ -10,11 +11,13 @@ namespace CodeBeam.UltimateAuth.Client.Services; internal class UAuthUserIdentifierClient : IUserIdentifierClient { private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; private readonly UAuthClientOptions _options; - public UAuthUserIdentifierClient(IUAuthRequestClient request, IOptions options) + public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) { _request = request; + _events = events; _options = options.Value; } @@ -30,36 +33,60 @@ public async Task>> GetMyIdentifiersA public async Task AddSelfAsync(AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode, request)); + } return UAuthResultMapper.From(raw); } public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode, request)); + } return UAuthResultMapper.From(raw); } public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.From(raw); } public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.From(raw); } public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.From(raw); } public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.UAuthStateRefreshMode)); + } return UAuthResultMapper.From(raw); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 308cc41d..e7a3b296 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; @@ -7,27 +8,29 @@ public interface ISessionStore Task ExecuteAsync(Func action, CancellationToken ct = default); Task ExecuteAsync(Func> action, CancellationToken ct = default); - Task GetSessionAsync(AuthSessionId sessionId); - Task SaveSessionAsync(UAuthSession session, long expectedVersion); - Task CreateSessionAsync(UAuthSession session); - Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at); + Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default); + Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); - Task GetChainAsync(SessionChainId chainId); - Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion); - Task CreateChainAsync(UAuthSessionChain chain); - Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at); - Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at); + Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default); + Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default); + Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); - Task GetRootByUserAsync(UserKey userKey); - Task GetRootByIdAsync(SessionRootId rootId); - Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion); - Task CreateRootAsync(UAuthSessionRoot root); - Task RevokeRootAsync(UserKey userKey, DateTimeOffset at); - Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at); + Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); + Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default); + Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default); + Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default); + Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); - Task GetChainIdBySessionAsync(AuthSessionId sessionId); - Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false); - Task> GetChainsByRootAsync(SessionRootId rootId); - Task> GetSessionsByChainAsync(SessionChainId chainId); - Task DeleteExpiredSessionsAsync(DateTimeOffset at); + Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default); + Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); + Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index bfb3dbd5..568a6788 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users; +using Microsoft.AspNetCore.Session; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Credentials.Reference; @@ -23,6 +24,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly IUAuthPasswordHasher _hasher; private readonly ITokenHasher _tokenHasher; private readonly ILoginIdentifierResolver _identifierResolver; + private readonly ISessionStoreFactory _sessionFactory; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -35,6 +37,7 @@ public CredentialManagementService( IUAuthPasswordHasher hasher, ITokenHasher tokenHasher, ILoginIdentifierResolver identifierResolver, + ISessionStoreFactory sessionFactory, IOptions options, IClock clock) { @@ -46,6 +49,7 @@ public CredentialManagementService( _hasher = hasher; _tokenHasher = tokenHasher; _identifierResolver = identifierResolver; + _sessionFactory = sessionFactory; _options = options.Value; _clock = clock; } @@ -110,7 +114,6 @@ public async Task AddAsync(AccessContext context, AddCreden return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - // TODO: Invalidate sessions or tokens associated with the credential when changing secret or revoking public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -146,6 +149,16 @@ public async Task ChangeSecretAsync(AccessContext contex var updated = pwd.ChangeSecret(newHash, now); await _credentials.SaveAsync(updated, oldVersion, innerCt); + var sessionStore = _sessionFactory.Create(context.ResourceTenant); + if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) + { + await sessionStore.RevokeOtherChainsAsync(context.ResourceTenant, subjectUser, chainId, now, innerCt); + } + else + { + await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, subjectUser, now, innerCt); + } + return ChangeCredentialResult.Success(pwd.Type); }); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 7ef170f7..df97d2ff 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -86,8 +86,10 @@ public async Task ExecuteAsync(Func GetSessionAsync(AuthSessionId sessionId) + public async Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = await _db.Sessions .AsNoTracking() .SingleOrDefaultAsync(x => x.SessionId == sessionId); @@ -95,8 +97,10 @@ public async Task ExecuteAsync(Func RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); if (projection is null) @@ -140,8 +148,10 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs return true; } - public async Task GetChainAsync(SessionChainId chainId) + public async Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = await _db.Chains .AsNoTracking() .SingleOrDefaultAsync(x => x.ChainId == chainId); @@ -149,14 +159,15 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs return projection?.ToDomain(); } - public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = chain.ToProjection(); if (chain.Version != expectedVersion + 1) throw new InvalidOperationException("Chain version must be incremented by domain."); - // Concurrency için EF’e expectedVersion’ı original value olarak bildiriyoruz _db.Entry(projection).State = EntityState.Modified; _db.Entry(projection) @@ -166,8 +177,10 @@ public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) return Task.CompletedTask; } - public Task CreateChainAsync(UAuthSessionChain chain) + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (chain.Version != 0) throw new InvalidOperationException("New chain must have version 0."); @@ -178,10 +191,11 @@ public Task CreateChainAsync(UAuthSessionChain chain) return Task.CompletedTask; } - public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); if (projection is null) return; @@ -193,8 +207,55 @@ public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) _db.Chains.Update(chain.Revoke(at).ToProjection()); } - public async Task GetActiveSessionIdAsync(SessionChainId chainId) + public async Task RevokeOtherChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.ChainId != currentChainId && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await _db.Chains + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + !x.IsRevoked) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var chain = projection.ToDomain(); + + if (chain.IsRevoked) + continue; + + _db.Chains.Update(chain.Revoke(at).ToProjection()); + } + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return await _db.Chains .AsNoTracking() .Where(x => x.ChainId == chainId) @@ -202,10 +263,11 @@ public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) .SingleOrDefaultAsync(); } - public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId) + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId, CancellationToken ct = default) { - var projection = await _db.Chains - .SingleOrDefaultAsync(x => x.ChainId == chainId); + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId); if (projection is null) return; @@ -214,8 +276,10 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId _db.Chains.Update(projection); } - public async Task GetRootByUserAsync(UserKey userKey) + public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var rootProjection = await _db.Roots .AsNoTracking() .SingleOrDefaultAsync(x => x.UserKey == userKey); @@ -231,8 +295,10 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId return rootProjection.ToDomain(); } - public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = root.ToProjection(); if (root.Version != expectedVersion + 1) @@ -247,8 +313,10 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) return Task.CompletedTask; } - public Task CreateRootAsync(UAuthSessionRoot root) + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (root.Version != 0) throw new InvalidOperationException("New root must have version 0."); @@ -259,8 +327,10 @@ public Task CreateRootAsync(UAuthSessionRoot root) return Task.CompletedTask; } - public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) + public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); if (projection is null) @@ -270,8 +340,10 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) _db.Roots.Update(root.Revoke(at).ToProjection()); } - public async Task GetChainIdBySessionAsync(AuthSessionId sessionId) + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + return await _db.Sessions .AsNoTracking() .Where(x => x.SessionId == sessionId) @@ -279,8 +351,10 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) .SingleOrDefaultAsync(); } - public async Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false) + public async Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var rootsQuery = _db.Roots.AsNoTracking().Where(r => r.UserKey == userKey); if (!includeHistoricalRoots) @@ -297,8 +371,10 @@ public async Task> GetChainsByUserAsync(UserKey return projections.Select(c => c.ToDomain()).ToList(); } - public async Task> GetChainsByRootAsync(SessionRootId rootId) + public async Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projections = await _db.Chains .AsNoTracking() .Where(x => x.RootId == rootId) @@ -307,8 +383,10 @@ public async Task> GetChainsByRootAsync(Session return projections.Select(x => x.ToDomain()).ToList(); } - public async Task> GetSessionsByChainAsync(SessionChainId chainId) + public async Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projections = await _db.Sessions .AsNoTracking() .Where(x => x.ChainId == chainId) @@ -317,8 +395,10 @@ public async Task> GetSessionsByChainAsync(SessionCh return projections.Select(x => x.ToDomain()).ToList(); } - public async Task GetRootByIdAsync(SessionRootId rootId) + public async Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var rootProjection = await _db.Roots .AsNoTracking() .SingleOrDefaultAsync(x => x.RootId == rootId); @@ -334,8 +414,10 @@ public async Task> GetSessionsByChainAsync(SessionCh return rootProjection.ToDomain(); } - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) + public async Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var projections = await _db.Sessions .Where(x => x.ExpiresAt <= at && !x.IsRevoked) .ToListAsync(); @@ -347,8 +429,10 @@ public async Task DeleteExpiredSessionsAsync(DateTimeOffset at) } } - public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) + public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var chainProjection = await _db.Chains .SingleOrDefaultAsync(x => x.ChainId == chainId); @@ -374,8 +458,10 @@ public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset } } - public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at) + public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.UserKey == userKey); if (rootProjection is null) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 6baab3dc..6abaf69a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Sessions.InMemory; @@ -40,11 +41,16 @@ public async Task ExecuteAsync(Func GetSessionAsync(AuthSessionId sessionId) - => Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + } - public Task SaveSessionAsync(UAuthSession session, long expectedVersion) + public Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (!_sessions.TryGetValue(session.SessionId, out var current)) throw new UAuthNotFoundException("session_not_found"); @@ -55,8 +61,10 @@ public Task SaveSessionAsync(UAuthSession session, long expectedVersion) return Task.CompletedTask; } - public Task CreateSessionAsync(UAuthSession session) + public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + lock (_lock) { if (_sessions.ContainsKey(session.SessionId)) @@ -71,8 +79,10 @@ public Task CreateSessionAsync(UAuthSession session) return Task.CompletedTask; } - public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (!_sessions.TryGetValue(sessionId, out var session)) return Task.FromResult(false); @@ -83,11 +93,16 @@ public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at) return Task.FromResult(true); } - public Task GetChainAsync(SessionChainId chainId) - => Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + public Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + } - public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (!_chains.TryGetValue(chain.ChainId, out var current)) throw new UAuthNotFoundException("chain_not_found"); @@ -98,8 +113,10 @@ public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion) return Task.CompletedTask; } - public Task CreateChainAsync(UAuthSessionChain chain) + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + lock (_lock) { if (_chains.ContainsKey(chain.ChainId)) @@ -114,8 +131,10 @@ public Task CreateChainAsync(UAuthSessionChain chain) return Task.CompletedTask; } - public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (_chains.TryGetValue(chainId, out var chain)) { _chains[chainId] = chain.Revoke(at); @@ -123,14 +142,63 @@ public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at) return Task.CompletedTask; } - public Task GetRootByUserAsync(UserKey userKey) - => Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + public Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.TryGetValue(userKey, out var r) ? r : null); + } - public Task GetRootByIdAsync(SessionRootId rootId) - => Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + public Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + } - public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (!_roots.TryGetValue(root.UserKey, out var current)) throw new UAuthNotFoundException("root_not_found"); @@ -141,8 +209,10 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion) return Task.CompletedTask; } - public Task CreateRootAsync(UAuthSessionRoot root) + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + lock (_lock) { if (_roots.ContainsKey(root.UserKey)) @@ -157,8 +227,10 @@ public Task CreateRootAsync(UAuthSessionRoot root) return Task.CompletedTask; } - public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) + public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (_roots.TryGetValue(userKey, out var root)) { _roots[userKey] = root.Revoke(at); @@ -166,16 +238,20 @@ public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at) return Task.CompletedTask; } - public Task GetChainIdBySessionAsync(AuthSessionId sessionId) + public Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (_sessions.TryGetValue(sessionId, out var session)) return Task.FromResult(session.ChainId); return Task.FromResult(null); } - public Task> GetChainsByUserAsync(UserKey userKey,bool includeHistoricalRoots = false) + public Task> GetChainsByUserAsync(UserKey userKey,bool includeHistoricalRoots = false, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var roots = _roots.Values.Where(r => r.UserKey == userKey); if (!includeHistoricalRoots) @@ -189,23 +265,26 @@ public Task> GetChainsByUserAsync(UserKey userK return Task.FromResult>(result); } - public Task> GetChainsByRootAsync(SessionRootId rootId) + public Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var result = _chains.Values.Where(c => c.RootId == rootId).ToList().AsReadOnly(); return Task.FromResult>(result); } - public Task> GetSessionsByChainAsync(SessionChainId chainId) + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) { - var result = _sessions.Values - .Where(s => s.ChainId == chainId) - .ToList(); + ct.ThrowIfCancellationRequested(); + var result = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); return Task.FromResult>(result); } - public Task DeleteExpiredSessionsAsync(DateTimeOffset at) + public Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + foreach (var kvp in _sessions) { var session = kvp.Value; @@ -225,8 +304,10 @@ public Task DeleteExpiredSessionsAsync(DateTimeOffset at) return Task.CompletedTask; } - public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) + public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + lock (_lock) { if (!_chains.TryGetValue(chainId, out var chain)) @@ -253,8 +334,10 @@ public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at) return Task.CompletedTask; } - public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at) + public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + lock (_lock) { if (!_roots.TryGetValue(userKey, out var root)) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs index ce89dea8..051da1bc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs @@ -14,6 +14,9 @@ public sealed record UserViewDto public string? Bio { get; init; } public DateOnly? BirthDate { get; init; } public string? Gender { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } public bool EmailVerified { get; init; } public bool PhoneVerified { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 81cea8c2..d1713b6b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -15,6 +15,9 @@ public static UserViewDto ToDto(UserProfile profile) BirthDate = profile.BirthDate, CreatedAt = profile.CreatedAt, Gender = profile.Gender, + Culture = profile.Culture, + Language = profile.Language, + TimeZone = profile.TimeZone, Metadata = profile.Metadata }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 1fc8b37d..0d5fc691 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -4,8 +4,6 @@ using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Contracts; @@ -19,12 +17,10 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserLifecycleStore _lifecycleStore; private readonly IUserProfileStore _profileStore; private readonly IUserIdentifierStore _identifierStore; - private readonly ICredentialStore _credentialStore; private readonly IUserCreateValidator _userCreateValidator; private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; private readonly IIdentifierNormalizer _identifierNormalizer; - private readonly IUAuthPasswordHasher _passwordHasher; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -33,12 +29,10 @@ public UserApplicationService( IUserLifecycleStore lifecycleStore, IUserProfileStore profileStore, IUserIdentifierStore identifierStore, - ICredentialStore credentialStore, IUserCreateValidator userCreateValidator, IIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, - IUAuthPasswordHasher passwordHasher, IOptions options, IClock clock) { @@ -46,12 +40,10 @@ public UserApplicationService( _lifecycleStore = lifecycleStore; _profileStore = profileStore; _identifierStore = identifierStore; - _credentialStore = credentialStore; _userCreateValidator = userCreateValidator; _identifierValidator = identifierValidator; _integrations = integrations; _identifierNormalizer = identifierNormalizer; - _passwordHasher = passwordHasher; _options = options.Value; _clock = clock; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs index 32805066..1ee40234 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Client; -using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; @@ -38,11 +38,11 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta var client = new Mock(); client.Setup(x => x.Flows).Returns(flowClient.Object); - var sessionEvents = new Mock(); + var events = new Mock(); var clock = new Mock(); clock.Setup(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); - var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); await manager.EnsureAsync(); await manager.EnsureAsync(); @@ -51,31 +51,39 @@ public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_sta } [Fact] - public async Task EnsureAsync_force_should_always_validate() + public async Task EnsureAsync_should_deduplicate_concurrent_calls() { - var client = new Mock(); - var sessionEvents = new Mock(); - var clock = new Mock(); + var flows = new Mock(); - client.Setup(x => x.Flows.ValidateAsync()) - .ReturnsAsync(new AuthValidationResult + flows.Setup(x => x.ValidateAsync()) + .Returns(async () => { - State = SessionState.Invalid + await Task.Delay(50); + return new AuthValidationResult { State = SessionState.Invalid }; }); - var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); + var client = new Mock(); + client.SetupGet(x => x.Flows).Returns(flows.Object); - await manager.EnsureAsync(force: true); - await manager.EnsureAsync(force: true); + var events = new Mock(); + var clock = new Mock(); + + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); + + await Task.WhenAll( + manager.EnsureAsync(true), + manager.EnsureAsync(true), + manager.EnsureAsync(true) + ); - client.Verify(x => x.Flows.ValidateAsync(), Times.Exactly(2)); + flows.Verify(x => x.ValidateAsync(), Times.Once); } [Fact] public async Task EnsureAsync_invalid_should_clear_state() { var client = new Mock(); - var sessionEvents = new Mock(); + var events = new Mock(); var clock = new Mock(); client.Setup(x => x.Flows.ValidateAsync()) @@ -84,10 +92,8 @@ public async Task EnsureAsync_invalid_should_clear_state() State = SessionState.Invalid }); - var manager = new UAuthStateManager(client.Object, sessionEvents.Object, clock.Object); - + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); await manager.EnsureAsync(); - manager.State.IsAuthenticated.Should().BeFalse(); } } From da72d5e43b8a9e3e8083245288c9a85301b0400d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 9 Mar 2026 00:51:42 +0300 Subject: [PATCH 21/29] User Self Deletion --- .../Components/Pages/Home.razor | 4 ++ .../Components/Pages/Home.razor.cs | 29 +++++++- .../Components/Pages/Login.razor | 6 +- .../AuthState/UAuthStateEvent.cs | 4 +- .../AuthState/UAuthStateManager.cs | 2 +- .../Services/IUserClient.cs | 1 + .../Services/UAuthCredentialClient.cs | 3 +- .../Services/UAuthUserClient.cs | 10 +++ .../Defaults/UAuthActions.cs | 1 + .../Abstractions/IUserEndpointHandler.cs | 1 + .../Endpoints/UAuthEndpointRegistrar.cs | 4 ++ .../Commands/AddUserIdentifierCommand.cs | 15 ---- .../Commands/ChangeUserIdentifierCommand.cs | 15 ---- .../Commands/ChangeUserStatusCommand.cs | 15 ---- .../Commands/CreateUserCommand.cs | 16 ----- .../Commands/DeleteUserCommand.cs | 15 ---- .../Commands/DeleteUserIdentifierCommand.cs | 15 ---- .../Commands/GetMeCommand.cs | 16 ----- .../Commands/GetUserIdentifierCommand.cs | 16 ----- .../Commands/GetUserIdentifiersCommand.cs | 17 ----- .../Commands/GetUserProfileCommand.cs | 16 ----- .../SetPrimaryUserIdentifierCommand.cs | 15 ---- .../UnsetPrimaryUserIdentifierCommand.cs | 15 ---- .../Commands/UpdateUserIdentifierCommand.cs | 15 ---- .../Commands/UpdateUserProfileAdminCommand.cs | 15 ---- .../Commands/UpdateUserProfileCommand.cs | 15 ---- .../Commands/UserIdentifierExistsCommand.cs | 15 ---- .../Commands/VerifyUserIdentifierCommand.cs | 15 ---- .../Endpoints/UserEndpointHandler.cs | 16 +++++ .../Services/IUserApplicationService.cs | 1 + .../Services/UserApplicationService.cs | 72 ++++++++++++++----- 31 files changed, 126 insertions(+), 289 deletions(-) delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 6f9c2b8f..23adec40 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -237,6 +237,10 @@ Manage Credentials + + Delete Account + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index a9fe309a..8ac21a76 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -3,6 +3,8 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; using System.Security.Claims; @@ -98,9 +100,30 @@ private async Task Validate() } } - private Task CreateUser() => Task.CompletedTask; - private Task AssignRole() => Task.CompletedTask; - private Task ChangePassword() => Task.CompletedTask; + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete"); + + if (info != true) + { + Snackbar.Add("Deletion cancelled", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + } + } private Color GetHealthColor() { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index 5472eb31..495a755d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -110,14 +110,14 @@ Programmatic Login - Login programmatically as admin/admin. + Login programmatically as admin/admin. - Forgot Password - Don't have an account? SignUp + Forgot Password + Don't have an account? SignUp diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs index 82f317fc..1b9ffd73 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -6,7 +6,9 @@ public enum UAuthStateEvent IdentifiersChanged, ProfileChanged, CredentialsChanged, + CredentialsChangedSelf, RolesChanged, PermissionsChanged, - SessionRevoked + SessionRevoked, + UserDeleted } diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs index f98305e0..13ce0c43 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -80,7 +80,7 @@ private async Task HandleStateEvent(UAuthStateEventArgs args) return; } - if (args.Type == UAuthStateEvent.CredentialsChanged) + if (args.Type == UAuthStateEvent.CredentialsChanged || args.Type == UAuthStateEvent.UserDeleted) { State.Clear(); return; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs index 88914fff..5c23dfe5 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs @@ -9,6 +9,7 @@ public interface IUserClient Task> CreateAsync(CreateUserRequest request); Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); + Task DeleteMeAsync(); Task> DeleteAsync(DeleteUserRequest request); Task> GetMeAsync(); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 262662c3..be890014 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -4,7 +4,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Client.Services; @@ -41,7 +40,7 @@ public async Task> ChangeMyAsync(ChangeCrede var raw = await _request.SendJsonAsync(Url($"/credentials/change"), request); if (raw.Ok) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.UAuthStateRefreshMode)); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.UAuthStateRefreshMode)); } return UAuthResultMapper.FromJson(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 021ee7b3..3ad44aa2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -39,6 +39,16 @@ public async Task UpdateMeAsync(UpdateProfileRequest request) return UAuthResultMapper.From(raw); } + public async Task DeleteMeAsync() + { + var raw = await _request.SendJsonAsync(Url("/users/me/delete")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateRefreshMode.Patch)); + } + return UAuthResultMapper.From(raw); + } + public async Task> CreateAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/users/create"), request); diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 51d2d5b9..0cf5172b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -38,6 +38,7 @@ public static class Users { public const string CreateAnonymous = "users.create.anonymous"; public const string CreateAdmin = "users.create.admin"; + public const string DeleteSelf = "users.delete.self"; public const string DeleteAdmin = "users.delete.admin"; public const string ChangeStatusSelf = "users.status.change.self"; public const string ChangeStatusAdmin = "users.status.change.admin"; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index 63223f91..df97b972 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -8,6 +8,7 @@ public interface IUserEndpointHandler Task CreateAsync(HttpContext ctx); Task ChangeStatusSelfAsync(HttpContext ctx); Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteMeAsync(HttpContext ctx); Task DeleteAsync(UserKey userKey, HttpContext ctx); Task GetMeAsync(HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 0cc93fba..752c3e3c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -137,6 +137,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + users.MapPost("/me/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs deleted file mode 100644 index 1b7b8e20..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class AddUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public AddUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs deleted file mode 100644 index 4279660d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs deleted file mode 100644 index 954c6fa6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserStatusCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class ChangeUserStatusCommand : IAccessCommand -{ - private readonly Func _execute; - - public ChangeUserStatusCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs deleted file mode 100644 index 675ebde9..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class CreateUserCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CreateUserCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs deleted file mode 100644 index da4acc96..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs deleted file mode 100644 index 59b9d079..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/DeleteUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DeleteUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public DeleteUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs deleted file mode 100644 index 90302ae1..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetMeCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetMeCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs deleted file mode 100644 index d5eb10f2..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifierCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserIdentifierCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs deleted file mode 100644 index fe8e594f..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserIdentifiersCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserIdentifiersCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs deleted file mode 100644 index 82e7fe12..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserProfileCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserProfileCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 8a56df8c..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs deleted file mode 100644 index 48a7ad89..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UnsetPrimaryUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs deleted file mode 100644 index 1005453d..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs deleted file mode 100644 index aa38706a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileAdminCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileAdminCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileAdminCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs deleted file mode 100644 index b102c86b..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateUserProfileCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateUserProfileCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs deleted file mode 100644 index 28602a36..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UserIdentifierExistsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public UserIdentifierExistsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs deleted file mode 100644 index 186433d6..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/VerifyUserIdentifierCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class VerifyUserIdentifierCommand : IAccessCommand -{ - private readonly Func _execute; - - public VerifyUserIdentifierCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index fce89093..a4fee3e5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -143,6 +143,22 @@ public async Task UpdateUserAsync(UserKey userKey, HttpContext ctx) return Results.Ok(); } + public async Task DeleteMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.DeleteSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.DeleteMeAsync(accessContext, ctx.RequestAborted); + return Results.Ok(); + } + public async Task DeleteAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 541b456d..fab67a4b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -32,5 +32,6 @@ public interface IUserApplicationService Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 0d5fc691..d15d4dda 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -3,7 +3,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Users.Contracts; @@ -21,6 +20,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly ISessionStoreFactory _sessionStoreFactory; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -33,6 +33,7 @@ public UserApplicationService( IIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, + ISessionStoreFactory sessionStoreFactory, IOptions options, IClock clock) { @@ -44,6 +45,7 @@ public UserApplicationService( _identifierValidator = identifierValidator; _integrations = integrations; _identifierNormalizer = identifierNormalizer; + _sessionStoreFactory = sessionStoreFactory; _options = options.Value; _clock = clock; } @@ -52,7 +54,7 @@ public UserApplicationService( public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) { - var command = new CreateUserCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var validationResult = await _userCreateValidator.ValidateAsync(context, request, innerCt); if (validationResult.IsValid != true) @@ -139,7 +141,7 @@ await _identifierStore.AddAsync( public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) { - var command = new ChangeUserStatusCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var newStatus = request switch { @@ -171,9 +173,45 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var lifecycleKey = new UserLifecycleKey(context.ResourceTenant, userKey); + var lifecycle = await _lifecycleStore.GetAsync(lifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + var profileKey = new UserProfileKey(context.ResourceTenant, userKey); + var profile = await _profileStore.GetAsync(profileKey, innerCt); + + await _lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenant, userKey, DeleteMode.Soft, now, innerCt); + + if (profile is not null) + { + await _profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, userKey, DeleteMode.Soft, innerCt); + } + + var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(context.ResourceTenant, userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) { - var command = new DeleteUserCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -210,7 +248,7 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) { - var command = new GetMeCommand(async innerCt => + var command = new AccessCommand(async innerCt => { if (context.ActorUserKey is null) throw new UnauthorizedAccessException(); @@ -223,11 +261,9 @@ public async Task GetMeAsync(AccessContext context, CancellationTok public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) { - var command = new GetUserProfileCommand(async innerCt => + var command = new AccessCommand(async innerCt => { - // Target user MUST exist in context var targetUserKey = context.GetTargetUserKey(); - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); }); @@ -237,7 +273,7 @@ public async Task GetUserProfileAsync(AccessContext context, Cancel public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) { - var command = new UpdateUserProfileCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var tenant = context.ResourceTenant; var userKey = context.GetTargetUserKey(); @@ -271,7 +307,7 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) { - var command = new GetUserIdentifiersCommand(async innerCt => + var command = new AccessCommand>(async innerCt => { var targetUserKey = context.GetTargetUserKey(); @@ -295,7 +331,7 @@ public async Task> GetIdentifiersByUserAsync(Acce public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) { - var command = new GetUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var normalized = _identifierNormalizer.Normalize(type, value); if (!normalized.IsValid) @@ -310,7 +346,7 @@ public async Task> GetIdentifiersByUserAsync(Acce public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default) { - var command = new UserIdentifierExistsCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var normalized = _identifierNormalizer.Normalize(type, value); if (!normalized.IsValid) @@ -327,7 +363,7 @@ public async Task UserIdentifierExistsAsync(AccessContext context, UserIde public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) { - var command = new AddUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var validationDto = new UserIdentifierDto() { Type = request.Type, Value = request.Value }; var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); @@ -397,7 +433,7 @@ await _identifierStore.AddAsync( public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UpdateUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -471,7 +507,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new SetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -500,7 +536,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) { - var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -537,7 +573,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) { - var command = new VerifyUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); @@ -555,7 +591,7 @@ public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIde public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) { - var command = new DeleteUserIdentifierCommand(async innerCt => + var command = new AccessCommand(async innerCt => { EnsureOverrideAllowed(context); From 2e826f715989fb55a1dbab296c8cccf51ee40dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 9 Mar 2026 15:37:49 +0300 Subject: [PATCH 22/29] Added Self User Status Change --- .../Custom/UAuthPageComponent.razor | 11 ++ .../Dialogs/AccountStatusDialog.razor | 106 ++++++++++++++++++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Layout/MainLayout.razor.cs | 54 +++++++++ .../Components/Pages/Home.razor | 29 ++++- .../Components/Pages/Home.razor.cs | 54 ++++----- .../AuthState/UAuthState.cs | 13 +++ .../AuthState/UAuthStateEvent.cs | 1 + .../AuthState/UAuthStateManager.cs | 6 +- .../IAuthorizationClient.cs | 0 .../{ => Abstractions}/ICredentialClient.cs | 0 .../{ => Abstractions}/IFlowClient.cs | 0 .../{ => Abstractions}/ISessionClient.cs | 0 .../{ => Abstractions}/IUAuthClient.cs | 0 .../{ => Abstractions}/IUserClient.cs | 0 .../IUserIdentifierClient.cs | 0 .../Contracts/Auth/AuthIdentitySnapshot.cs | 1 + .../Contracts/User}/UserStatus.cs | 4 +- .../Domain/User/UserRuntimeRecord.cs | 1 + .../Auth/AuthStateSnapshotFactory.cs | 20 ++-- .../Flows/Login/LoginOrchestrator.cs | 2 +- .../Services/UAuthFlowService.cs | 30 ++--- .../Policies/RequireActiveUserPolicy.cs | 4 +- .../Dtos/SelfUserStatus.cs | 7 ++ .../Dtos/Snapshots/UserLifecycleSnapshot.cs | 10 ++ .../{ => Snapshots}/UserProfileSnapshot.cs | 0 .../Mappers/UserStatusMapper.cs | 19 ++++ .../Requests/ChangeUserStatusAdminRequest.cs | 3 +- .../Requests/ChangeUserStatusSelfRequest.cs | 2 +- .../Responses/UserStatusChangeResult.cs | 4 +- .../Contracts/UserLifecycleQuery.cs | 1 - .../Domain/UserLifecycle.cs | 2 +- .../Extensions/ServiceCollectonExtensions.cs | 1 + .../UserLifecycleSnaphotProvider.cs | 29 +++++ .../Services/UserApplicationService.cs | 25 ++--- .../Stores/UserRuntimeStateProvider.cs | 3 +- .../IUserLifecycleSnapshotProvider.cs | 10 ++ .../Client/AuthStateSnapshotFactoryTests.cs | 6 +- 38 files changed, 376 insertions(+), 83 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IAuthorizationClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/ICredentialClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IFlowClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/ISessionClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUAuthClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUserClient.cs (100%) rename src/CodeBeam.UltimateAuth.Client/Services/{ => Abstractions}/IUserIdentifierClient.cs (100%) rename src/{users/CodeBeam.UltimateAuth.Users.Contracts/Dtos => CodeBeam.UltimateAuth.Core/Contracts/User}/UserStatus.cs (73%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{ => Snapshots}/UserProfileSnapshot.cs (100%) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor new file mode 100644 index 00000000..da2001a5 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor @@ -0,0 +1,11 @@ + + + @ChildContent + + + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor new file mode 100644 index 00000000..44d359f0 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,106 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Suspend Account + + + + Delete Account + + + + + +@code { + + private MudForm? _form; + + private string? _firstName; + private string? _lastName; + private string? _displayName; + + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + + private string? _language; + private string? _timeZone; + private string? _culture; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task SuspendAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to suspend your account.

+ You can still active your account later. + """, + yesText: "Suspend", noText: "Cancel"); + + if (info != true) + { + Snackbar.Add("Suspend process cancelled", Severity.Info); + return; + } + + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); + } + } + + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete", noText: "Cancel"); + + if (info != true) + { + Snackbar.Add("Deletion cancelled", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Delete failed.", Severity.Error); + } + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 937b438c..240591d9 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -34,6 +34,7 @@ + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs index 8ee2d479..47d68df7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -1,5 +1,8 @@ using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using Microsoft.AspNetCore.Components; using MudBlazor; @@ -56,6 +59,57 @@ private void HandleSignInClick() GoToLoginWithReturn(); } + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active • Tenant: {result.Snapshot?.Identity?.Tenant.Value} • User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + private void GoToLoginWithReturn() { var uri = Nav.ToAbsoluteUri(Nav.Uri); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 23adec40..36c7925a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -8,6 +8,25 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @using System.Security.Claims +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Custom + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + Logout + + + + return; +} @@ -23,7 +42,7 @@ @AuthState?.Identity?.DisplayName - @foreach (var role in AuthState.Claims.Roles) + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) { @role @@ -68,7 +87,7 @@ Authenticated
- @(AuthState.IsAuthenticated ? "Yes" : "No") + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") @@ -237,8 +256,8 @@ Manage Credentials - - Delete Account + + Suspend | Delete Account @@ -252,7 +271,7 @@ - @if (AuthState.IsInRole("Admin") || _showAdminPreview) + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 8ac21a76..3b6dbc58 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using MudBlazor; using System.Security.Claims; @@ -62,6 +62,11 @@ private async Task Validate() if (result.IsValid) { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } Snackbar.Add($"Session active • Tenant: {result.Snapshot?.Identity?.Tenant.Value} • User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); } else @@ -100,31 +105,6 @@ private async Task Validate() } } - private async Task DeleteAccountAsync() - { - var info = await DialogService.ShowMessageBoxAsync( - title: "Are You Sure", - markupMessage: (MarkupString) - """ - You are going to delete your account.

- This action can't be undone.

- (Actually it is, admin can handle soft deleted accounts.) - """, - yesText: "Delete"); - - if (info != true) - { - Snackbar.Add("Deletion cancelled", Severity.Info); - return; - } - - var result = await UAuthClient.Users.DeleteMeAsync(); - if (result.IsSuccess) - { - Snackbar.Add("Your account deleted successfully.", Severity.Success); - } - } - private Color GetHealthColor() { if (Diagnostics.RefreshReauthRequiredCount > 0) @@ -194,6 +174,11 @@ private async Task OpenCredentialDialog() await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), GetDialogOptions()); } + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), GetDialogOptions()); + } + private DialogOptions GetDialogOptions() { return new DialogOptions @@ -205,13 +190,28 @@ private DialogOptions GetDialogOptions() } private DialogParameters GetDialogParameters() - { + { return new DialogParameters { ["AuthState"] = AuthState }; } + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfUserStatus.Active }; + var result = await UAuthClient.Users.ChangeStatusSelfAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + public override void Dispose() { base.Dispose(); diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index dd71847d..f1881f8e 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -58,6 +58,19 @@ internal void UpdateProfile(UpdateProfileRequest req) Changed?.Invoke(UAuthStateChangeReason.Patched); } + internal void UpdateUserStatus(ChangeUserStatusSelfRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + UserStatus = UserStatusMapper.ToUserStatus(req.NewStatus) + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + internal void MarkValidated(DateTimeOffset now) { if (!IsAuthenticated) diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs index 1b9ffd73..ba50a3bd 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -4,6 +4,7 @@ public enum UAuthStateEvent { ValidationCalled, IdentifiersChanged, + UserStatusChanged, ProfileChanged, CredentialsChanged, CredentialsChangedSelf, diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs index 13ce0c43..21dd4d40 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -91,6 +91,10 @@ private async Task HandleStateEvent(UAuthStateEventArgs args) case UAuthStateEventArgs profile: State.UpdateProfile(profile.Payload); break; + + case UAuthStateEventArgs profile: + State.UpdateUserStatus(profile.Payload); + break; } switch (args.RefreshMode) @@ -104,7 +108,7 @@ private async Task HandleStateEvent(UAuthStateEventArgs args) { State.MarkValidated(_clock.UtcNow); } - if (args.Type == UAuthStateEvent.IdentifiersChanged) + if (args.Type == UAuthStateEvent.UserStatusChanged) { } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/IFlowClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/ISessionClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs rename to src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs index 1c07c5ad..4f3812bf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -14,4 +14,5 @@ public sealed record AuthIdentitySnapshot public DateTimeOffset? AuthenticatedAt { get; init; } public SessionState? SessionState { get; init; } public string? TimeZone { get; init; } + public UserStatus UserStatus { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs similarity index 73% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs index 9aff5b20..f822f02c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +namespace CodeBeam.UltimateAuth.Core.Contracts; public enum UserStatus { @@ -16,5 +16,5 @@ public enum UserStatus PendingActivation = 60, PendingVerification = 70, - Deactivated = 80, + Unknown = 99 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs index 6e042d46..8629eb13 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -4,6 +4,7 @@ public sealed record UserRuntimeRecord { public UserKey UserKey { get; init; } public bool IsActive { get; init; } + public bool CanAuthenticate { get; init; } public bool IsDeleted { get; init; } public bool Exists { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 84bdbcb1..76e977f3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -6,13 +6,15 @@ namespace CodeBeam.UltimateAuth.Server.Auth { internal sealed class AuthStateSnapshotFactory : IAuthStateSnapshotFactory { - private readonly IPrimaryUserIdentifierProvider _identifierProvider; - private readonly IUserProfileSnapshotProvider _profileSnapshotProvider; + private readonly IPrimaryUserIdentifierProvider _identifier; + private readonly IUserProfileSnapshotProvider _profile; + private readonly IUserLifecycleSnapshotProvider _lifecycle; - public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvider, IUserProfileSnapshotProvider profileSnapshotProvider) + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUserProfileSnapshotProvider profile, IUserLifecycleSnapshotProvider lifecycle) { - _identifierProvider = identifierProvider; - _profileSnapshotProvider = profileSnapshotProvider; + _identifier = identifier; + _profile = profile; + _lifecycle = lifecycle; } public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) @@ -20,8 +22,9 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide if (!validation.IsValid || validation.UserKey is null) return null; - var identifiers = await _identifierProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); - var profile = await _profileSnapshotProvider.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var identifiers = await _identifier.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var lifecycle = await _lifecycle.GetAsync(validation.Tenant, validation.UserKey.Value, ct); var identity = new AuthIdentitySnapshot { @@ -33,7 +36,8 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifierProvide DisplayName = profile?.DisplayName, TimeZone = profile?.TimeZone, AuthenticatedAt = validation.AuthenticatedAt, - SessionState = validation.State + SessionState = validation.State, + UserStatus = lifecycle?.Status ?? UserStatus.Unknown }; return new AuthStateSnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 30d3f91a..c05f1c02 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -74,7 +74,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (userKey is not null) { var user = await _users.GetAsync(request.Tenant, userKey.Value, ct); - if (user is not null && user.IsActive && !user.IsDeleted) + if (user is not null && user.CanAuthenticate && !user.IsDeleted) { userExists = true; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 1ad74409..caafda9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -31,21 +31,6 @@ public UAuthFlowService( _events = events; } - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { return _loginOrchestrator.LoginAsync(flow, request, ct); @@ -106,4 +91,19 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio { throw new NotImplementedException(); } + + public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs index 711f961b..ba1cbd35 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -36,13 +36,11 @@ public bool AppliesTo(AccessContext context) if (context.Action.EndsWith(".anonymous")) return false; - return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + return !AllowedForInactive.Contains(context.Action); } private static readonly string[] AllowedForInactive = { UAuthActions.Users.ChangeStatusSelf, - "login.", - "reauth." }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs new file mode 100644 index 00000000..752fc7eb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfUserStatus.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum SelfUserStatus +{ + Active = 0, + SelfSuspended = 10, +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs new file mode 100644 index 00000000..0b1b6b39 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserLifecycleSnapshot +{ + public UserKey UserKey { get; init; } + public UserStatus Status { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs similarity index 100% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileSnapshot.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs new file mode 100644 index 00000000..b1b30e2b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public static class UserStatusMapper +{ + public static UserStatus ToUserStatus(this SelfUserStatus selfStatus) + { + switch (selfStatus) + { + case SelfUserStatus.Active: + return UserStatus.Active; + case SelfUserStatus.SelfSuspended: + return UserStatus.SelfSuspended; + default: + throw new NotImplementedException(); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index 5b7561e3..cb0d9521 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Users.Contracts; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs index dba43740..f9c82d6e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -2,5 +2,5 @@ public class ChangeUserStatusSelfRequest { - public required UserStatus NewStatus { get; init; } + public required SelfUserStatus NewStatus { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs index e355cd9a..25706af4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserStatusChangeResult { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs index 90a7a9ac..b1dc66e0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index f4bcbd3d..8ee2470b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 19040a2e..2cfabc66 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); return services; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs new file mode 100644 index 00000000..82350491 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserLifecycleSnapshotProvider : IUserLifecycleSnapshotProvider +{ + private readonly IUserLifecycleStore _store; + + public UserLifecycleSnapshotProvider(IUserLifecycleStore store) + { + _store = store; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var profile = await _store.GetAsync(new UserLifecycleKey(tenant, userKey), ct); + + if (profile is null || profile.IsDeleted) + return null; + + return new UserLifecycleSnapshot + { + UserKey = profile.UserKey, + Status = profile.Status + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index d15d4dda..38634126 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -145,7 +145,7 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C { var newStatus = request switch { - ChangeUserStatusSelfRequest r => r.NewStatus, + ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), ChangeUserStatusAdminRequest r => r.NewStatus, _ => throw new InvalidOperationException("invalid_request") }; @@ -156,15 +156,15 @@ public async Task ChangeUserStatusAsync(AccessContext context, object request, C var now = _clock.UtcNow; if (current is null) - throw new InvalidOperationException("user_not_found"); + throw new UAuthNotFoundException("user_not_found"); if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) - throw new InvalidOperationException("self_transition_not_allowed"); + throw new UAuthConflictException("self_transition_not_allowed"); if (!context.IsSelfAction) { - if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) - throw new InvalidOperationException("admin_cannot_set_self_status"); + if (newStatus is UserStatus.SelfSuspended) + throw new UAuthConflictException("admin_cannot_set_self_status"); } var newEntity = current.ChangeStatus(now, newStatus); await _lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); @@ -672,35 +672,35 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL return; if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) - throw new InvalidOperationException("multiple_usernames_not_allowed"); + throw new UAuthValidationException("multiple_usernames_not_allowed"); if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) - throw new InvalidOperationException("multiple_emails_not_allowed"); + throw new UAuthValidationException("multiple_emails_not_allowed"); if (type == UserIdentifierType.Phone && !_options.Identifiers.AllowMultiplePhone) - throw new InvalidOperationException("multiple_phones_not_allowed"); + throw new UAuthValidationException("multiple_phones_not_allowed"); } private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) { - throw new InvalidOperationException("email_verification_required"); + throw new UAuthValidationException("email_verification_required"); } if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) { - throw new InvalidOperationException("phone_verification_required"); + throw new UAuthValidationException("phone_verification_required"); } } private void EnsureOverrideAllowed(AccessContext context) { if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) - throw new InvalidOperationException("user_override_not_allowed"); + throw new UAuthConflictException("user_override_not_allowed"); if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) - throw new InvalidOperationException("admin_override_not_allowed"); + throw new UAuthConflictException("admin_override_not_allowed"); } private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) @@ -708,7 +708,6 @@ private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) { (UserStatus.Active, UserStatus.SelfSuspended) => true, (UserStatus.SelfSuspended, UserStatus.Active) => true, - (UserStatus.Active or UserStatus.SelfSuspended, UserStatus.Deactivated) => true, _ => false }; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs index a21204d3..4a4e16fb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -26,6 +26,7 @@ public UserRuntimeStateProvider(IUserLifecycleStore lifecycleStore) { UserKey = lifecycle.UserKey, IsActive = lifecycle.Status == UserStatus.Active, + CanAuthenticate = lifecycle.Status == UserStatus.Active || lifecycle.Status == UserStatus.SelfSuspended || lifecycle.Status == UserStatus.Suspended, IsDeleted = lifecycle.IsDeleted, Exists = true }; diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs new file mode 100644 index 00000000..afaad513 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserLifecycleSnapshotProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs index 01d86911..aca9fa48 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -16,6 +16,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() { var provider = new Mock(); var pprovider = new Mock(); + var lprovider = new Mock(); provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) .ReturnsAsync(new PrimaryUserIdentifiers @@ -23,7 +24,7 @@ public async Task CreateAsync_should_return_snapshot_when_valid() UserName = "admin" }); - var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Active( TenantKey.FromInternal("__single__"), @@ -46,8 +47,9 @@ public async Task CreateAsync_should_return_null_when_invalid() { var provider = new Mock(); var pprovider = new Mock(); + var lprovider = new Mock(); - var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object); + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); var validation = SessionValidationResult.Invalid(SessionState.NotFound); var snapshot = await factory.CreateAsync(validation); From 5f2de3aa92d341f58d239177c328309ff661aa94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 10 Mar 2026 01:12:14 +0300 Subject: [PATCH 23/29] Complete Logout & Revoke Semantics & Added Tests --- .../Dialogs/AccountStatusDialog.razor | 15 - .../Components/Dialogs/SessionDialog.razor | 289 +++++++++++++++--- .../AuthState/UAuthStateEvent.cs | 3 +- .../AuthState/UAuthStateManager.cs | 2 +- .../Services/Abstractions/IFlowClient.cs | 9 + .../Services/Abstractions/ISessionClient.cs | 2 +- .../Services/UAuthFlowClient.cs | 63 +++- .../Services/UAuthSessionClient.cs | 4 +- .../Abstractions/Stores/ISessionStore.cs | 6 +- .../Contracts/Login/LoginRequest.cs | 3 - .../Contracts/Revoke/RevokeResult.cs | 2 +- .../Session/Dtos/SessionChainDetailDto.cs | 35 ++- .../Session/Dtos/SessionChainSummaryDto.cs | 1 + .../Defaults/UAuthActions.cs | 10 + .../Domain/Session/SessionChainState.cs | 8 + .../Domain/Session/UAuthSessionChain.cs | 39 ++- .../Options/UAuthSessionOptions.cs | 4 - .../Abstractions/ILogoutEndpointHandler.cs | 10 +- .../Endpoints/LogoutEndpointHandler.cs | 124 +++++++- .../Endpoints/UAuthEndpointRegistrar.cs | 27 +- .../Flows/Login/LoginOrchestrator.cs | 25 +- .../Issuers/UAuthSessionIssuer.cs | 34 ++- .../Orchestrator/UAuthSessionOrchestrator.cs | 1 - .../Services/ISessionApplicationService.cs | 3 + .../Services/SessionApplicationService.cs | 155 +++++++--- .../Commands/AssignUserRoleCommand.cs | 15 - .../Commands/GetUserRolesCommand.cs | 15 - .../Commands/RemoveUserRoleCommand.cs | 15 - .../Commands/ActivateCredentialCommand.cs | 16 - .../Commands/AddCredentialCommand.cs | 16 - .../Commands/BeginCredentialResetCommand.cs | 16 - .../Commands/ChangeCredentialCommand.cs | 16 - .../CompleteCredentialResetCommand.cs | 16 - .../Commands/DeleteCredentialCommand.cs | 16 - .../Commands/GetAllCredentialsCommand.cs | 16 - .../Commands/RevokeCredentialCommand.cs | 16 - .../Commands/SetInitialCredentialCommand.cs | 15 - .../SessionChainProjection.cs | 4 +- .../Mappers/SessionChainProjectionMapper.cs | 2 - .../Stores/EfCoreSessionStore.cs | 127 +++++++- .../InMemorySessionStore.cs | 152 ++++++++- .../Requests/LogoutDeviceAdminRequest.cs | 9 + .../Requests/LogoutDeviceSelfRequest.cs | 8 + .../LogoutOtherDevicesAdminRequest.cs | 9 + .../Requests/LogoutOtherDevicesSelfRequest.cs | 8 + .../Fake/FakeFlowClient.cs | 31 ++ .../Helpers/TestAuthRuntime.cs | 16 + .../Helpers/TestHttpContext.cs | 3 +- .../Sessions/SessionTests.cs | 136 +++++++++ 49 files changed, 1202 insertions(+), 365 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor index 44d359f0..3ab84aa8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -23,21 +23,6 @@ @code { - - private MudForm? _form; - - private string? _firstName; - private string? _lastName; - private string? _displayName; - - private DateTime? _birthDate; - private string? _gender; - private string? _bio; - - private string? _language; - private string? _timeZone; - private string? _culture; - [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor index 2fb8108d..42fe5ce3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -12,42 +12,181 @@ User: @AuthState?.Identity?.DisplayName - - Logout All Devices - Logout Other Devices - - - - Identifiers - - - - - - - - - - - - - - - - - - - - - - - - - - - + @if (_chainDetail is not null) + { + + + + Device Details + + + + @if (!_chainDetail.IsRevoked) + { + + Revoke Device + + } + + + + + + + Device Type + @_chainDetail.DeviceType + + + + Platform + @_chainDetail.Platform + + + + Operating System + @_chainDetail.OperatingSystem + + + + Browser + @_chainDetail.Browser + + + + Created + @_chainDetail.CreatedAt.ToLocalTime() + + + + Last Seen + @_chainDetail.LastSeenAt.ToLocalTime() + + + + State + + @_chainDetail.State + + + + + Active Session + @_chainDetail.ActiveSessionId + + + + Rotation Count + @_chainDetail.RotationCount + + + + Touch Count + @_chainDetail.TouchCount + + + + + + Session History + + + + Session Id + Created + Expires + Status + + + + @context.SessionId + @context.CreatedAt.ToLocalTime() + @context.ExpiresAt.ToLocalTime() + + @if (context.IsRevoked) + { + Revoked + } + else + { + Active + } + + + + + } + else + { + + Logout All Devices + Logout Other Devices + Revoke All Devices + Revoke Other Devices + + + + Identifiers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.ChainId + + + + Created At + @context.Item.CreatedAt + + + Touch Count + @context.Item.TouchCount + + + + Rotation Count + @context.Item.RotationCount + + + + + + + + + } Cancel @@ -59,6 +198,7 @@ private MudDataGrid? _grid; private bool _loading = false; private bool _reloadQueued; + private SessionChainDetailDto? _chainDetail; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; @@ -147,11 +287,59 @@ } private async Task LogoutAllAsync() + { + var result = await UAuthClient.Flows.LogoutAllDevicesSelfAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + + var result = await UAuthClient.Flows.LogoutOtherDevicesSelfAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutDeviceAsync(SessionChainId chainId) + { + LogoutDeviceSelfRequest request = new() { ChainId = chainId }; + var result = await UAuthClient.Flows.LogoutDeviceSelfAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device.", Severity.Success); + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to logout", Severity.Error); + } + } + + private async Task RevokeAllAsync() { var result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); if (result.IsSuccess) { - Snackbar.Add("Logged out of all devices", Severity.Success); + Snackbar.Add("Logged out of all devices.", Severity.Success); Nav.NavigateTo("/login"); } else @@ -160,12 +348,12 @@ } } - private async Task LogoutAllExceptThisAsync() + private async Task RevokeOthersAsync() { var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); if (result.IsSuccess) { - Snackbar.Add("Logged out of all other devices", Severity.Success); + Snackbar.Add("Revoked all other devices.", Severity.Success); await ReloadAsync(); } else @@ -174,14 +362,14 @@ } } - private async Task LogoutChainAsync(SessionChainId chainId) + private async Task RevokeChainAsync(SessionChainId chainId) { var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); if (result.IsSuccess) { - Snackbar.Add("Logged out of device", Severity.Success); + Snackbar.Add("Device revoked successfully.", Severity.Success); - if (result?.Value?.CurrentSessionRevoked == true) + if (result?.Value?.CurrentChain == true) { Nav.NavigateTo("/login"); return; @@ -194,6 +382,25 @@ } } + private async Task ShowChainDetailsAsync(SessionChainId chainId) + { + var result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); + if (result.IsSuccess) + { + _chainDetail = result.Value; + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to fetch chain details.", Severity.Error); + _chainDetail = null; + } + } + + private void ClearDetail() + { + _chainDetail = null; + } + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); private void Cancel() => MudDialog.Cancel(); diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs index ba50a3bd..23411055 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -11,5 +11,6 @@ public enum UAuthStateEvent RolesChanged, PermissionsChanged, SessionRevoked, - UserDeleted + UserDeleted, + LogoutVariant } diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs index 21dd4d40..880edf76 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -80,7 +80,7 @@ private async Task HandleStateEvent(UAuthStateEventArgs args) return; } - if (args.Type == UAuthStateEvent.CredentialsChanged || args.Type == UAuthStateEvent.UserDeleted) + if (args.Type == UAuthStateEvent.CredentialsChanged || args.Type == UAuthStateEvent.UserDeleted || args.Type == UAuthStateEvent.LogoutVariant) { State.Clear(); return; diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs index bec146de..ea759dff 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; // TODO: Add ReauthAsync namespace CodeBeam.UltimateAuth.Client.Services; @@ -14,4 +16,11 @@ public interface IFlowClient Task BeginPkceAsync(string? returnUrl = null); Task CompletePkceLoginAsync(PkceLoginRequest request); + + Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request); + Task LogoutOtherDevicesSelfAsync(); + Task LogoutAllDevicesSelfAsync(); + Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId); + Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request); + Task LogoutAllDevicesAdminAsync(UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs index 30824b9a..da156b33 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs @@ -13,7 +13,7 @@ public interface ISessionClient Task>> GetUserChainsAsync(UserKey userKey); - Task> GetUserChainAsync(UserKey userKey, SessionChainId chainId); + Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId); Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId); Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId); Task RevokeUserRootAsync(UserKey userKey); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 287ef712..e161c500 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using System.Net; @@ -234,6 +235,65 @@ public async Task CompletePkceLoginAsync(PkceLoginRequest request) await _post.NavigateAsync(url, payload); } + public async Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/logout-device"), request); + + if (raw.Ok) + { + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentChain == true) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.UAuthStateRefreshMode)); + } + + return result; + } + + return UAuthResultMapper.FromJson(raw); + } + + public async Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId) + { + LogoutDeviceAdminRequest request = new() { UserKey = userKey, ChainId = chainId }; + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-device/{userKey.Value}"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task LogoutOtherDevicesSelfAsync() + { + var raw = await _post.SendJsonAsync(Url("/logout-others")); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-others/{request.UserKey.Value}"), request); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllDevicesSelfAsync() + { + var raw = await _post.SendJsonAsync(Url("/logout-all")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.UAuthStateRefreshMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllDevicesAdminAsync(UserKey userKey) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/logout-all/{userKey.Value}")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.UAuthStateRefreshMode)); + } + return UAuthResultMapper.From(raw); + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl) { var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); @@ -249,9 +309,6 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi return _post.NavigateAsync(hubLoginUrl, data); } - - // ---------------- PKCE CRYPTO ---------------- - private static string CreateVerifier() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index 4a3a74fe..dc1aaa7a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -42,7 +42,7 @@ public async Task> RevokeMyChainAsync(SessionChainId c var raw = await _request.SendJsonAsync(Url($"/session/me/chains/{chainId}/revoke")); var result = UAuthResultMapper.FromJson(raw); - if (result.Value?.CurrentSessionRevoked == true) + if (result.Value?.CurrentChain == true) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.UAuthStateRefreshMode)); } @@ -76,7 +76,7 @@ public async Task>> GetUserChain return UAuthResultMapper.FromJson>(raw); } - public async Task> GetUserChainAsync(UserKey userKey, SessionChainId chainId) + public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) { var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/sessions/chains/{chainId}")); return UAuthResultMapper.FromJson(raw); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index e7a3b296..53fd7b31 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -12,6 +12,9 @@ public interface ISessionStore Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default); Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default); Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default); Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default); @@ -20,6 +23,7 @@ public interface ISessionStore Task RevokeOtherChainsAsync(TenantKey tenant, UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); Task RevokeAllChainsAsync(TenantKey tenant, UserKey user, DateTimeOffset at, CancellationToken ct = default); Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default); @@ -30,7 +34,7 @@ public interface ISessionStore Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default); Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default); + Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default); Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); - Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index c8cebe98..c85b9b92 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -17,7 +17,4 @@ public sealed record LoginRequest /// Server policy may still ignore this. ///
public bool RequestTokens { get; init; } = true; - - // Optional - public SessionChainId? ChainId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs index 1c3f2797..0a980aca 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs @@ -2,7 +2,7 @@ { public sealed record RevokeResult { - public bool CurrentSessionRevoked { get; init; } + public bool CurrentChain { get; init; } public bool RootRevoked { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs index 3843e4e7..edea1d4b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetailDto.cs @@ -2,14 +2,27 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record SessionChainDetailDto( - SessionChainId ChainId, - string? DeviceName, - string? DeviceType, - DateTimeOffset CreatedAt, - DateTimeOffset? LastSeenAt, - int RotationCount, - bool IsRevoked, - AuthSessionId? ActiveSessionId, - IReadOnlyList Sessions - ); +public sealed class SessionChainDetailDto +{ + public SessionChainId ChainId { get; init; } + + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; init; } + + public SessionChainState State { get; init; } + + public int RotationCount { get; init; } + public int TouchCount { get; init; } + + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + + public AuthSessionId? ActiveSessionId { get; init; } + + public IReadOnlyList Sessions { get; init; } = []; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs index 0ad1c471..51debfc8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummaryDto.cs @@ -17,4 +17,5 @@ public sealed record SessionChainSummaryDto public DateTimeOffset? RevokedAt { get; init; } public AuthSessionId? ActiveSessionId { get; init; } public bool IsCurrentDevice { get; init; } + public SessionChainState State { get; set; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 0cf5172b..e9dbc7dc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -19,6 +19,16 @@ public static string Create(string resource, string operation, ActionScope scope : $"{resource}.{subResource}.{operation}.{scopePart}"; } + public static class Flows + { + public const string LogoutDeviceSelf = "flows.logoutdevice.self"; + public const string LogoutDeviceAdmin = "flows.logoutdevice.admin"; + public const string LogoutOthersSelf = "flows.logoutothers.self"; + public const string LogoutOthersAdmin = "flows.logoutothers.admin"; + public const string LogoutAllSelf = "flows.logoutall.self"; + public const string LogoutAllAdmin = "flows.logoutall.admin"; + } + public static class Sessions { public const string GetChainSelf = "sessions.getchain.self"; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs new file mode 100644 index 00000000..dde7b81d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public enum SessionChainState +{ + Active = 0, + Passive = 10, + Revoked = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 4e195a0c..7d9139cc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -20,10 +21,13 @@ public sealed class UAuthSessionChain : IVersionedEntity public int TouchCount { get; } public long SecurityVersionAtCreation { get; } - public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long Version { get; set; } + + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; + private UAuthSessionChain( SessionChainId chainId, SessionRootId rootId, @@ -38,7 +42,6 @@ private UAuthSessionChain( int rotationCount, int touchCount, long securityVersionAtCreation, - bool isRevoked, DateTimeOffset? revokedAt, long version) { @@ -55,7 +58,6 @@ private UAuthSessionChain( RotationCount = rotationCount; TouchCount = touchCount; SecurityVersionAtCreation = securityVersionAtCreation; - IsRevoked = isRevoked; RevokedAt = revokedAt; Version = version; } @@ -85,7 +87,6 @@ public static UAuthSessionChain Create( rotationCount: 0, touchCount: 0, securityVersionAtCreation: securityVersion, - isRevoked: false, revokedAt: null, version: 0 ); @@ -113,7 +114,30 @@ public UAuthSessionChain AttachSession(AuthSessionId sessionId, DateTimeOffset n RotationCount, // Unchanged on first attach TouchCount, SecurityVersionAtCreation, - IsRevoked, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain DetachSession(DateTimeOffset now) + { + if (ActiveSessionId is null) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + ClaimsSnapshot, + activeSessionId: null, + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, RevokedAt, Version + 1 ); @@ -141,7 +165,6 @@ public UAuthSessionChain RotateSession(AuthSessionId sessionId, DateTimeOffset n RotationCount + 1, TouchCount, SecurityVersionAtCreation, - IsRevoked, RevokedAt, Version + 1 ); @@ -166,7 +189,6 @@ public UAuthSessionChain Touch(DateTimeOffset now, ClaimsSnapshot? claimsSnapsho RotationCount, TouchCount + 1, SecurityVersionAtCreation, - IsRevoked, RevokedAt, Version + 1 ); @@ -191,7 +213,6 @@ public UAuthSessionChain Revoke(DateTimeOffset now) RotationCount, TouchCount, SecurityVersionAtCreation, - isRevoked: true, revokedAt: now, Version + 1 ); @@ -211,7 +232,6 @@ internal static UAuthSessionChain FromProjection( int rotationCount, int touchCount, long securityVersionAtCreation, - bool isRevoked, DateTimeOffset? revokedAt, long version) { @@ -229,7 +249,6 @@ internal static UAuthSessionChain FromProjection( rotationCount, touchCount, securityVersionAtCreation, - isRevoked, revokedAt, version ); diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs index 007cad76..1514da03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -59,10 +59,6 @@ public sealed class UAuthSessionOptions /// /// Maximum number of session rotations within a single chain. /// Used for cleanup, replay protection, and analytics. - /// - /// NOTE: - /// Enforcement is not active in v0.0.1. - /// This option is reserved for future security policies. /// public int MaxSessionsPerChain { get; set; } = 100; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs index 424560f2..583229f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -1,8 +1,16 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; public interface ILogoutEndpointHandler { Task LogoutAsync(HttpContext ctx); + Task LogoutDeviceSelfAsync(HttpContext ctx); + Task LogoutOthersSelfAsync(HttpContext ctx); + Task LogoutAllSelfAsync(HttpContext ctx); + + Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 15d01de7..69de30c4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -1,10 +1,14 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints; @@ -13,14 +17,18 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler { private readonly IAuthFlowContextAccessor _authContext; private readonly IUAuthFlowService _flow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessionApplicationService; private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; + _accessContextFactory = accessContextFactory; + _sessionApplicationService = sessionApplicationService; _clock = clock; _cookieManager = cookieManager; _redirectResolver = redirectResolver; @@ -53,6 +61,120 @@ public async Task LogoutAsync(HttpContext ctx) : Results.Ok(new LogoutResponse { Success = true }); } + public async Task LogoutDeviceSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceSelf, + resource: "flows", + resourceId: userKey.Value); + + var result = await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceAdmin, + resource: "flows", + resourceId: request.UserKey.Value); + + await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutOthersSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersSelf, + resource: "flows", + resourceId: userKey.Value); + + if (access.ActorChainId is not SessionChainId chainId) + return Results.Unauthorized(); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersAdmin, + resource: "flows", + resourceId: request.UserKey.Value); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, request.CurrentChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllSelf, + resource: "flows", + resourceId: userKey); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllAdmin, + resource: "flows", + resourceId: userKey.Value); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) { if (delivery.Mode != TokenResponseMode.Cookie) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 752c3e3c..4205c4af 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -28,6 +28,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); + //var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); + if (options.Endpoints.Login != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) @@ -39,11 +43,30 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + group.MapPost("/logout-device", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + group.MapPost("/logout-others", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + group.MapPost("/logout-all", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); + + + adminUsers.MapPost("/logout-device/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + adminUsers.MapPost("/logout-others/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + adminUsers.MapPost("/logout-all/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); } if (options.Endpoints.Pkce != false) @@ -74,10 +97,6 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); } - //var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); - if (options.Endpoints.Session != false) { var session = group.MapGroup("/session"); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index c05f1c02..694def16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Credentials; @@ -27,6 +28,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator private readonly ISessionOrchestrator _sessionOrchestrator; private readonly ITokenIssuer _tokens; private readonly IUserClaimsProvider _claimsProvider; + private readonly ISessionStoreFactory _storeFactory; private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; private readonly UAuthServerOptions _options; @@ -40,6 +42,7 @@ public LoginOrchestrator( ISessionOrchestrator sessionOrchestrator, ITokenIssuer tokens, IUserClaimsProvider claimsProvider, + ISessionStoreFactory storeFactory, IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, IOptions options) @@ -52,6 +55,7 @@ public LoginOrchestrator( _sessionOrchestrator = sessionOrchestrator; _tokens = tokens; _claimsProvider = claimsProvider; + _storeFactory = storeFactory; _authenticationSecurityManager = authenticationSecurityManager; _events = events; _options = options.Value; @@ -61,6 +65,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { ct.ThrowIfCancellationRequested(); + if (flow.Device.DeviceId is not DeviceId deviceId) + throw new UAuthConflictException("Device id could not resolved."); + var now = request.At ?? DateTimeOffset.UtcNow; var resolution = await _identifierResolver.ResolveAsync(request.Tenant, request.Identifier, ct); var userKey = resolution?.UserKey; @@ -111,6 +118,18 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } } + // TODO: Add create-time uniqueness guard for chain id for concurrency + var kernel = _storeFactory.Create(request.Tenant); + SessionChainId? chainId = null; + + if (userKey is not null) + { + var chain = await kernel.GetChainByDeviceAsync(request.Tenant, userKey.Value, deviceId, ct); + + if (chain is not null && !chain.IsRevoked) + chainId = chain.ChainId; + } + // TODO: Add accountState here, currently it only checks factor state var decisionContext = new LoginDecisionContext { @@ -120,7 +139,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req UserExists = userExists, UserKey = userKey, SecurityState = factorState, - IsChained = request.ChainId is not null + IsChained = chainId is not null }; var decision = _authority.Decide(decisionContext); @@ -191,7 +210,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Now = now, Device = flow.Device, Claims = claims, - ChainId = request.ChainId, + ChainId = chainId, Metadata = SessionMetadata.Empty, Mode = flow.EffectiveMode }; @@ -208,7 +227,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Tenant = request.Tenant, UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, + ChainId = issuedSession.Session.ChainId, Claims = claims.AsDictionary() }; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 1dc924f2..dc9ce475 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -12,13 +12,13 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UAuthSessionIssuer : ISessionIssuer { - private readonly ISessionStoreFactory _kernelFactory; + private readonly ISessionStoreFactory _storeFactory; private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(ISessionStoreFactory kernelFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) { - _kernelFactory = kernelFactory; + _storeFactory = storeFactory; _opaqueGenerator = opaqueGenerator; _options = options.Value; } @@ -45,7 +45,7 @@ public async Task IssueSessionAsync(AuthenticatedSessionContext c expiresAt = absoluteExpiry; } - var kernel = _kernelFactory.Create(context.Tenant); + var kernel = _storeFactory.Create(context.Tenant); IssuedSession? issued = null; @@ -93,6 +93,22 @@ await kernel.ExecuteAsync(async _ => await kernel.CreateChainAsync(chain); } + var sessions = await kernel.GetSessionsByChainAsync(chain.ChainId); + + if (sessions.Count >= _options.Session.MaxSessionsPerChain) + { + var toDelete = sessions + .Where(s => s.SessionId != chain.ActiveSessionId) + .OrderBy(x => x.CreatedAt) + .Take(sessions.Count - _options.Session.MaxSessionsPerChain + 1) + .ToList(); + + foreach (var old in toDelete) + { + await kernel.RemoveSessionAsync(old.SessionId); + } + } + var session = UAuthSession.Create( sessionId: sessionId, tenant: context.Tenant, @@ -125,7 +141,7 @@ await kernel.ExecuteAsync(async _ => public async Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(context.Tenant); + var kernel = _storeFactory.Create(context.Tenant); var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); @@ -207,13 +223,13 @@ await kernel.ExecuteAsync(async _ => public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); return await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); } public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); await kernel.ExecuteAsync(async _ => { var chain = await kernel.GetChainAsync(chainId); @@ -226,7 +242,7 @@ await kernel.ExecuteAsync(async _ => public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); await kernel.ExecuteAsync(async _ => { var chains = await kernel.GetChainsByUserAsync(userKey); @@ -243,7 +259,7 @@ await kernel.ExecuteAsync(async _ => public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { - var kernel = _kernelFactory.Create(tenant); + var kernel = _storeFactory.Create(tenant); await kernel.ExecuteAsync(async _ => { var root = await kernel.GetRootByUserAsync(userKey); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index bc55c658..e541fb7e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -39,5 +39,4 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi return await command.ExecuteAsync(authContext, _issuer, ct); } - } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs index d1d2f178..3078e595 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionApplicationService.cs @@ -16,6 +16,9 @@ public interface ISessionApplicationService Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default); Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default); + Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index e0ad5274..8e6d6ad9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Server.Services; @@ -31,30 +32,45 @@ public async Task> GetUserChainsAsync(Access { chains = request.SortBy switch { - nameof(SessionChainSummaryDto.ChainId) => - request.Descending - ? chains.OrderByDescending(x => x.ChainId).ToList() - : chains.OrderBy(x => x.Version).ToList(), - - nameof(SessionChainSummaryDto.CreatedAt) => - request.Descending - ? chains.OrderByDescending(x => x.CreatedAt).ToList() - : chains.OrderBy(x => x.Version).ToList(), - - nameof(SessionChainSummaryDto.DeviceType) => - request.Descending - ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() - : chains.OrderBy(x => x.Version).ToList(), - - nameof(SessionChainSummaryDto.Platform) => - request.Descending - ? chains.OrderByDescending(x => x.Device.Platform).ToList() - : chains.OrderBy(x => x.Version).ToList(), - - nameof(SessionChainSummaryDto.RotationCount) => - request.Descending - ? chains.OrderByDescending(x => x.RotationCount).ToList() - : chains.OrderBy(x => x.RotationCount).ToList(), + nameof(SessionChainSummaryDto.ChainId) => request.Descending + ? chains.OrderByDescending(x => x.ChainId).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.CreatedAt) => request.Descending + ? chains.OrderByDescending(x => x.CreatedAt).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummaryDto.LastSeenAt) => request.Descending + ? chains.OrderByDescending(x => x.LastSeenAt).ToList() + : chains.OrderBy(x => x.LastSeenAt).ToList(), + + nameof(SessionChainSummaryDto.RevokedAt) => request.Descending + ? chains.OrderByDescending(x => x.RevokedAt).ToList() + : chains.OrderBy(x => x.RevokedAt).ToList(), + + nameof(SessionChainSummaryDto.DeviceType) => request.Descending + ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() + : chains.OrderBy(x => x.Device.DeviceType).ToList(), + + nameof(SessionChainSummaryDto.OperatingSystem) => request.Descending + ? chains.OrderByDescending(x => x.Device.OperatingSystem).ToList() + : chains.OrderBy(x => x.Device.OperatingSystem).ToList(), + + nameof(SessionChainSummaryDto.Platform) => request.Descending + ? chains.OrderByDescending(x => x.Device.Platform).ToList() + : chains.OrderBy(x => x.Device.Platform).ToList(), + + nameof(SessionChainSummaryDto.Browser) => request.Descending + ? chains.OrderByDescending(x => x.Device.Browser).ToList() + : chains.OrderBy(x => x.Device.Browser).ToList(), + + nameof(SessionChainSummaryDto.RotationCount) => request.Descending + ? chains.OrderByDescending(x => x.RotationCount).ToList() + : chains.OrderBy(x => x.RotationCount).ToList(), + + nameof(SessionChainSummaryDto.TouchCount) => request.Descending + ? chains.OrderByDescending(x => x.TouchCount).ToList() + : chains.OrderBy(x => x.TouchCount).ToList(), _ => chains }; @@ -79,7 +95,8 @@ public async Task> GetUserChainsAsync(Access IsRevoked = c.IsRevoked, RevokedAt = c.RevokedAt, ActiveSessionId = c.ActiveSessionId, - IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value + IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value, + State = c.State, }) .ToList(); @@ -94,24 +111,38 @@ public async Task GetUserChainDetailAsync(AccessContext c var command = new AccessCommand(async innerCt => { var store = _storeFactory.Create(context.ResourceTenant); - - var chain = await store.GetChainAsync(chainId) ?? throw new InvalidOperationException("chain_not_found"); + var chain = await store.GetChainAsync(chainId) ?? throw new UAuthNotFoundException("chain_not_found"); if (chain.UserKey != userKey) - throw new UnauthorizedAccessException(); + throw new UAuthValidationException("User conflict."); var sessions = await store.GetSessionsByChainAsync(chainId); - return new SessionChainDetailDto( - chain.ChainId, - null, - null, - DateTimeOffset.MinValue, - null, - chain.RotationCount, - chain.IsRevoked, - chain.ActiveSessionId, - sessions.Select(s => new SessionInfoDto(s.SessionId, s.CreatedAt, s.ExpiresAt, s.IsRevoked)).ToList()); + return new SessionChainDetailDto + { + ChainId = chain.ChainId, + DeviceType = chain.Device.DeviceType, + OperatingSystem = chain.Device.OperatingSystem, + Platform = chain.Device.Platform, + Browser = chain.Device.Browser, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + State = chain.State, + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt, + ActiveSessionId = chain.ActiveSessionId, + + Sessions = sessions + .OrderByDescending(x => x.CreatedAt) + .Select(s => new SessionInfoDto( + s.SessionId, + s.CreatedAt, + s.ExpiresAt, + s.IsRevoked)) + .ToList() + }; }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -149,7 +180,7 @@ public async Task RevokeUserChainAsync(AccessContext context, User return new RevokeResult { - CurrentSessionRevoked = isCurrent, + CurrentChain = isCurrent, RootRevoked = false }; }); @@ -181,6 +212,52 @@ public async Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, S await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var isCurrent = context.ActorChainId == currentChainId; + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.LogoutChainAsync(currentChainId, now, innerCt); + + return new RevokeResult + { + CurrentChain = isCurrent, + RootRevoked = false + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeOtherSessionsAsync(userKey, currentChainId, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeAllSessionsAsync(userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + public async Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs deleted file mode 100644 index 52d62db5..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/AssignUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class AssignUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public AssignUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs deleted file mode 100644 index a57a1b16..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/GetUserRolesCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class GetUserRolesCommand : IAccessCommand> -{ - private readonly Func>> _execute; - - public GetUserRolesCommand(Func>> execute) - { - _execute = execute; - } - - public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs deleted file mode 100644 index 35693190..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Commands/RemoveUserRoleCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.Reference; - -internal sealed class RemoveUserRoleCommand : IAccessCommand -{ - private readonly Func _execute; - - public RemoveUserRoleCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs deleted file mode 100644 index d496e7f7..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ActivateCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public ActivateCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs deleted file mode 100644 index e0426f87..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class AddCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public AddCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs deleted file mode 100644 index 7cd3bde0..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class BeginCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public BeginCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs deleted file mode 100644 index f9117ea8..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class ChangeCredentialCommand: IAccessCommand -{ - private readonly Func> _execute; - - public ChangeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs deleted file mode 100644 index 7bf3a0f6..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class CompleteCredentialResetCommand : IAccessCommand -{ - private readonly Func> _execute; - - public CompleteCredentialResetCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs deleted file mode 100644 index ae290b3d..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class DeleteCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public DeleteCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs deleted file mode 100644 index 098d01bc..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class GetAllCredentialsCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetAllCredentialsCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs deleted file mode 100644 index 70bf7851..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class RevokeCredentialCommand : IAccessCommand -{ - private readonly Func> _execute; - - public RevokeCredentialCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs deleted file mode 100644 index 4efed9f9..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.Reference; - -internal sealed class SetInitialCredentialCommand : IAccessCommand -{ - private readonly Func _execute; - - public SetInitialCredentialCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index 9619d3f6..7106b024 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -22,7 +22,9 @@ internal sealed class SessionChainProjection public int TouchCount { get; set; } public long SecurityVersionAtCreation { get; set; } - public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long Version { get; set; } + + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 335e598e..683f9453 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -20,7 +20,6 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) p.RotationCount, p.TouchCount, p.SecurityVersionAtCreation, - p.IsRevoked, p.RevokedAt, p.Version ); @@ -43,7 +42,6 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) RotationCount = chain.RotationCount, TouchCount = chain.TouchCount, SecurityVersionAtCreation = chain.SecurityVersionAtCreation, - IsRevoked = chain.IsRevoked, RevokedAt = chain.RevokedAt, Version = chain.Version }; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index df97d2ff..65a2a1db 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -148,6 +148,68 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs return true; } + public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + + public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); + + var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + } + public async Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -159,6 +221,22 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs return projection?.ToDomain(); } + public async Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await _db.Chains + .AsNoTracking() + .Where(x => + x.Tenant == tenant && + x.UserKey == userKey && + x.RevokedAt == null && + x.Device.DeviceId == deviceId) + .SingleOrDefaultAsync(ct); + + return projection?.ToDomain(); + } + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -207,6 +285,40 @@ public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, Ca _db.Chains.Update(chain.Revoke(at).ToProjection()); } + public async Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await _db.Chains.SingleOrDefaultAsync(x => x.ChainId == chainId, ct); + + if (chainProjection is null) + return; + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + return; + + var sessions = await _db.Sessions.Where(x => x.ChainId == chainId).ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + continue; + + var revoked = session.Revoke(at); + _db.Sessions.Update(revoked.ToProjection()); + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _db.Chains.Update(updatedChain.ToProjection()); + } + } + public async Task RevokeOtherChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -414,19 +526,16 @@ public async Task> GetSessionsByChainAsync(SessionCh return rootProjection.ToDomain(); } - public async Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default) + public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var projections = await _db.Sessions - .Where(x => x.ExpiresAt <= at && !x.IsRevoked) - .ToListAsync(); + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - foreach (var p in projections) - { - var revoked = p.ToDomain().Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + if (projection is null) + return; + + _db.Sessions.Remove(projection); } public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 6abaf69a..73fc9452 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -89,10 +89,90 @@ public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, if (session.IsRevoked) return Task.FromResult(false); - _sessions[sessionId] = session.Revoke(at); + var revoked = session.Revoke(at); + _sessions[sessionId] = revoked; + + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(at); + _chains[chain.ChainId] = updatedChain; + } + } + return Task.FromResult(true); } + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + public Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -273,6 +353,20 @@ public Task> GetChainsByRootAsync(SessionRootId return Task.FromResult>(result); } + public Task GetChainByDeviceAsync(TenantKey tenant, UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chain = _chains.Values + .FirstOrDefault(c => + c.Tenant == tenant && + c.UserKey == userKey && + !c.IsRevoked && + c.Device.DeviceId == deviceId); + + return Task.FromResult(chain); + } + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -281,23 +375,24 @@ public Task> GetSessionsByChainAsync(SessionChainId return Task.FromResult>(result); } - public Task DeleteExpiredSessionsAsync(DateTimeOffset at, CancellationToken ct = default) + public Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - foreach (var kvp in _sessions) + lock (_lock) { - var session = kvp.Value; + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.CompletedTask; - if (session.ExpiresAt <= at) - { - var revoked = session.Revoke(at); - _sessions[kvp.Key] = revoked; + _sessions.TryRemove(sessionId, out _); - //if (_activeSessions.TryGetValue(revoked.ChainId, out var activeId) && activeId == revoked.SessionId) - //{ - // _activeSessions.TryRemove(revoked.ChainId, out _); - //} + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(DateTimeOffset.UtcNow); + _chains[chain.ChainId] = updatedChain; + } } } @@ -334,6 +429,39 @@ public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, C return Task.CompletedTask; } + public Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (chain.IsRevoked) + return Task.CompletedTask; + + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[chainId] = updatedChain; + } + } + + return Task.CompletedTask; + } + public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs new file mode 100644 index 00000000..d2a75bdd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceAdminRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutDeviceAdminRequest +{ + public required UserKey UserKey { get; init; } + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs new file mode 100644 index 00000000..82ef6c99 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceSelfRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutDeviceSelfRequest +{ + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs new file mode 100644 index 00000000..e9d053e8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesAdminRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesAdminRequest +{ + public required UserKey UserKey { get; init; } + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs new file mode 100644 index 00000000..6f6b4e97 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesSelfRequest.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class LogoutOtherDevicesSelfRequest +{ + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index f1572c2a..df05950c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -50,11 +51,41 @@ public Task LoginAsync(LoginRequest request, string? returnUrl) throw new NotImplementedException(); } + public Task LogoutAllDevicesAdminAsync(UserKey userKey) + { + throw new NotImplementedException(); + } + + public Task LogoutAllDevicesSelfAsync() + { + throw new NotImplementedException(); + } + public Task LogoutAsync() { throw new NotImplementedException(); } + public Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId) + { + throw new NotImplementedException(); + } + + public Task> LogoutDeviceSelfAsync(LogoutDeviceSelfRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesAdminAsync(LogoutOtherDevicesAdminRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesSelfAsync() + { + throw new NotImplementedException(); + } + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) { throw new NotImplementedException(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index 9827a126..26e6d480 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; @@ -89,4 +90,19 @@ public ICredentialManagementService GetCredentialManagementService() var scope = Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } + + public async Task LoginAsync(AuthFlowContext flow) + { + using var scope = Services.CreateScope(); + + var orchestrator = scope.ServiceProvider + .GetRequiredService(); + + return await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs index ef3d3deb..4545b326 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -13,7 +13,8 @@ public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile cl var resolvedTenant = tenant ?? TenantKey.Single; ctx.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); - + + ctx.Request.Headers["X-UDID"] = "test-device-000-000-000-000-01"; ctx.Request.Headers["User-Agent"] = "UltimateAuth-Test"; ctx.Request.Scheme = "https"; ctx.Request.Host = new HostString("app.example.com"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs new file mode 100644 index 00000000..dec7a17f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -0,0 +1,136 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class SessionTests +{ + [Fact] + public async Task Login_should_cleanup_old_sessions_when_limit_exceeded() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Session.MaxSessionsPerChain = 3; + }); + + var flow = await runtime.CreateLoginFlowAsync(); + for (int i = 0; i < 5; i++) + { + await runtime.LoginAsync(flow); + } + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var chain = chains.First(); + var sessions = await store.GetSessionsByChainAsync(chain.ChainId); + + sessions.Count.Should().BeLessThanOrEqualTo(3); + } + + [Fact] + public async Task Logout_device_should_revoke_sessions_but_keep_chain() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chainId = await store.GetChainIdBySessionAsync(result.SessionId!.Value); + await store.LogoutChainAsync(chainId!.Value, runtime.Clock.UtcNow); + var chain = await store.GetChainAsync(chainId.Value); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull(); + + var sessions = await store.GetSessionsByChainAsync(chainId.Value); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } + + [Fact] + public async Task Logout_other_devices_should_keep_current_chain() + { + var runtime = new TestAuthRuntime(); + + var flow1 = await runtime.CreateLoginFlowAsync(); + var flow2 = await runtime.CreateLoginFlowAsync(); + + await runtime.LoginAsync(flow1); + + await runtime.LoginAsync(flow2); + + var store = runtime.Services.GetRequiredService() + .Create(TenantKey.Single); + + var chains = await store.GetChainsByUserAsync(TestUsers.User); + + var current = chains.First(); + + await store.RevokeOtherSessionsAsync(current.UserKey, current.ChainId, runtime.Clock.UtcNow); + + var updatedChains = await store.GetChainsByUserAsync(current.UserKey); + + updatedChains.Count(x => x.State == SessionChainState.Active).Should().Be(1); + } + + [Fact] + public async Task Get_chain_detail_should_return_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var service = runtime.Services.GetRequiredService(); + var context = TestAccessContext.ForUser(TestUsers.User, "session.query"); + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var result = await service.GetUserChainDetailAsync(context, chains.First().UserKey, chains.First().ChainId); + + result.Should().NotBeNull(); + result.Sessions.Should().NotBeEmpty(); + } + + [Fact] + public async Task Revoke_chain_should_revoke_all_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Tenant = TenantKey.Single, + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + await store.RevokeChainCascadeAsync(chains.First().ChainId, runtime.Clock.UtcNow); + var sessions = await store.GetSessionsByChainAsync(chains.First().ChainId); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } +} From 123876a710cf6fcbe5666c8b7fe72f1571eecbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 10 Mar 2026 14:55:19 +0300 Subject: [PATCH 24/29] Home Page Design and ReauthRequired Raiese Event Test Fix --- .../Components/Dialogs/SessionDialog.razor | 4 +- .../Components/Layout/MainLayout.razor | 4 +- .../Components/Pages/Home.razor | 376 +++++++++--------- .../Components/Pages/Login.razor | 2 +- .../Program.cs | 14 +- .../wwwroot/app.css | 4 + .../Infrastructure/SessionCoordinator.cs | 102 ++++- .../Client/SessionCoordinatorTests.cs | 21 +- 8 files changed, 306 insertions(+), 221 deletions(-) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor index 42fe5ce3..414e65e4 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -14,7 +14,7 @@ @if (_chainDetail is not null) { - + Device Details @@ -29,7 +29,7 @@ } - + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 240591d9..9f6af9c4 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -7,8 +7,8 @@ UltimateAuth - - Blazor Server Sample + + Blazor Server Sample diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 36c7925a..f7d8a11b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -29,10 +29,103 @@ } - - - - + + + + + + + + + Session + + + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + Account + + + + + Manage Sessions + + + + Manage Profile + + + + Manage Identifiers + + + + Manage Credentials + + + + Suspend | Delete Account + + + + Admin + + + + + + + + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { + + + + Add User + + + + + Assign Role + + + + } + + + + + + + @@ -208,214 +301,121 @@ - - - - - Session - - - - - Validate - - - - - - Manual Refresh - - + + + + + @GetHealthText() + - - - Logout - - - + Lifecycle - + - Account - - Manage Sessions - - - - Manage Profile - - - - Manage Identifiers - - - - Manage Credentials - - - - Suspend | Delete Account - - - - - - Admin - - - - - - - - @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) { - - - - Add User - - - - - Assign Role - - - + + + + @FormatRelative(Diagnostics.StartedAt) + + } - - @if (_showAdminPreview) + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) { - - Admin operations are shown for preview. Sign in as an Admin to execute them. - + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + } + - - - - - - @GetHealthText() - + - Lifecycle + + Refresh Metrics + - + - - - Started - @Diagnostics.StartCount + + + Total Attempts + @Diagnostics.RefreshAttemptCount - @if (Diagnostics.StartedAt is not null) - { - - - - - @FormatRelative(Diagnostics.StartedAt) - - - - } - - - Stopped - - @Diagnostics.StopCount - + + + + Success + + @Diagnostics.RefreshSuccessCount - - - Terminated - @Diagnostics.TerminatedCount + + + Automatic + @Diagnostics.AutomaticRefreshCount - @if (Diagnostics.TerminatedAt is not null) - { - - - - - @FormatRelative(Diagnostics.TerminatedAt) - - - - } - - - - Refresh Metrics - - - - - - - Total Attempts - @Diagnostics.RefreshAttemptCount - - - - - - - Success - - @Diagnostics.RefreshSuccessCount - - - - - - Automatic - @Diagnostics.AutomaticRefreshCount - - - - - - Manual - @Diagnostics.ManualRefreshCount - - + + + Manual + @Diagnostics.ManualRefreshCount + + - - - Touched - @Diagnostics.RefreshTouchedCount - - + + + Touched + @Diagnostics.RefreshTouchedCount + + - - - No-Op - @Diagnostics.RefreshNoOpCount - - + + + No-Op + @Diagnostics.RefreshNoOpCount + + - - - Reauth Required - @Diagnostics.RefreshReauthRequiredCount - - - + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + - - + + + - \ No newline at end of file + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index 495a755d..378c90e7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -10,7 +10,7 @@ @inject IDialogService DialogService - + @code { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor index 3ab84aa8..7873ce91 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -38,7 +38,8 @@ You are going to suspend your account.

You can still active your account later. """, - yesText: "Suspend", noText: "Cancel"); + yesText: "Suspend", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); if (info != true) { @@ -69,7 +70,8 @@ This action can't be undone.

(Actually it is, admin can handle soft deleted accounts.) """, - yesText: "Delete", noText: "Cancel"); + yesText: "Delete", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); if (info != true) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index 228afa24..dc5b27f1 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -243,7 +243,8 @@ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. This will only mark the identifier as verified in UltimateAuth. """, - yesText: "Verify"); + yesText: "Verify", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); if (demoInfo != true) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor new file mode 100644 index 00000000..9f89c886 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,155 @@ +@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using System.Reflection + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Role Permissions + @Role.Name + + + + @* For Debug *@ + @* Current Permissions: @string.Join(", ", Role.Permissions) *@ + + @foreach (var group in _groups) + { + + + + + @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) + + + + + @foreach (var perm in group.Items) + { + + + + } + + + + } + + + + + Cancel + Save + + + +@code { + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleInfo Role { get; set; } = default!; + + private List _groups = new(); + + protected override void OnInitialized() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + _groups = catalog + .GroupBy(p => p.Split('.')[0]) + .Select(g => new PermissionGroup + { + Name = g.Key, + Items = g.Select(p => new PermissionItem + { + Value = p, + Selected = selected.Contains(p) + }).ToList() + }) + .OrderBy(x => x.Name) + .ToList(); + } + + void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + bool? GetGroupState(PermissionGroup group) + { + var selected = group.Items.Count(x => x.Selected); + + if (selected == 0) + return false; + + if (selected == group.Items.Count) + return true; + + return null; + } + + private async Task Save() + { + var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + + var req = new SetPermissionsRequest + { + Permissions = permissions + }; + + var res = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + + if (!res.IsSuccess) + { + Snackbar.Add(res.Problem?.Title ?? "Failed to update permissions", Severity.Error); + return; + } + + Role = (await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name })).Value!.Items.First(); + Snackbar.Add("Permissions updated", Severity.Success); + RefreshUI(); + } + + private void RefreshUI() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + foreach (var group in _groups) + { + foreach (var item in group.Items) + { + item.Selected = selected.Contains(item.Value); + } + } + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private class PermissionGroup + { + public string Name { get; set; } = ""; + public List Items { get; set; } = new(); + } + + private class PermissionItem + { + public string Value { get; set; } = ""; + public bool Selected { get; set; } + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor index deec31a1..540bc7ae 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -8,9 +8,7 @@ Role Management - - Manage system roles - + Manage system roles @@ -28,6 +26,16 @@ + + + @GetPermissionCount(context.Item) + + + + + + + @@ -84,21 +92,6 @@ [Parameter] public UAuthState AuthState { get; set; } = default!; - // protected override async Task OnAfterRenderAsync(bool firstRender) - // { - // await base.OnAfterRenderAsync(firstRender); - - // if (firstRender) - // { - // var result = await UAuthClient.Identifiers.GetMyIdentifiersAsync(); - // if (result != null && result.IsSuccess && result.Value != null) - // { - // await ReloadAsync(); - // StateHasChanged(); - // } - // } - // } - private async Task> LoadServerData(GridState state, CancellationToken ct) { var sort = state.SortDefinitions?.FirstOrDefault(); @@ -185,7 +178,8 @@ "Delete role", "Are you sure?", yesText: "Delete", - cancelText: "Cancel"); + cancelText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass="uauth-blur-slight" }); if (confirm != true) return; @@ -200,13 +194,27 @@ } else { - Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Delete failed", Severity.Error); + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Deletion failed.", Severity.Error); } } private async Task EditPermissions(RoleInfo role) { - // next dialog + var dialog = await DialogService.ShowAsync( + "Edit Permissions", + new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }, + new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Large, + FullWidth = true + }); + + var result = await dialog.Result; + await ReloadAsync(); } private async Task ReloadAsync() @@ -217,6 +225,12 @@ await _grid.ReloadServerData(); } + private int GetPermissionCount(RoleInfo role) + { + var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); + return expanded.Count; + } + private void Cancel() => MudDialog.Cancel(); } \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor index 9f6af9c4..7239bf28 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -4,7 +4,7 @@ @inject NavigationManager Nav - + UltimateAuth diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 6203ff0d..6cd64562 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -109,7 +109,7 @@ - + Role Management diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor index c4b58a61..2c0e9b77 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor @@ -1,6 +1,6 @@ @inject NavigationManager Nav -
+ @@ -24,4 +24,4 @@ -
+ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css index fe98a15d..2b9a4745 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -50,12 +50,6 @@ h1:focus { border-color: #929292; } -.uauth-page { - height: calc(100vh - var(--mud-appbar-height)); - width: 100vw; - margin-top: 64px; -} - .uauth-stack { min-height: 60vh; max-height: calc(100vh - var(--mud-appbar-height)); @@ -89,13 +83,23 @@ h1:focus { } .uauth-dialog { - min-height: 62vh; + height: 68vh; + max-height: 68vh; + overflow: auto; } .text-secondary { color: var(--mud-palette-text-secondary); } +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + @keyframes uauth-logo-float { 0% { transform: translateY(0) rotateY(0); diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index 4d9785fa..0dd58c6e 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -46,7 +46,6 @@ internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validated Changed?.Invoke(UAuthStateChangeReason.Authenticated); } - // TODO: Improve patch semantics with identifier, profile add, update or delete. internal void UpdateProfile(UpdateProfileRequest req) { if (Identity is null) diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs index 3adb7f27..35626d73 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -12,5 +12,8 @@ public static Permission From(string value) public static readonly Permission Wildcard = new("*"); + public bool IsWildcard => Value == "*"; + public bool IsPrefix => Value.EndsWith(".*"); + public override string ToString() => Value; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionInfo.cs deleted file mode 100644 index f7e93c3c..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record PermissionInfo -{ - public required string Value { get; init; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs index cd9bf401..67c61d8e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs @@ -4,4 +4,10 @@ public sealed record RoleInfo { public RoleId Id { get; init; } public required string Name { get; init; } + + public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? UpdatedAt { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs new file mode 100644 index 00000000..c076dbd4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionExpander +{ + public static IReadOnlyCollection Expand(IEnumerable stored, IEnumerable catalog) + { + var result = new HashSet(); + + foreach (var perm in stored) + { + if (perm.IsWildcard) + { + result.UnionWith(catalog); + continue; + } + + if (perm.IsPrefix) + { + var prefix = perm.Value[..^2]; + result.UnionWith(catalog.Where(x => x.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))); + continue; + } + + result.Add(perm.Value); + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs new file mode 100644 index 00000000..a9f64276 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs @@ -0,0 +1,43 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionNormalizer +{ + public static IReadOnlyCollection Normalize(IEnumerable permissions, IEnumerable catalog) + { + var selected = new HashSet(permissions.Select(p => p.Value), StringComparer.OrdinalIgnoreCase); + + if (selected.Contains("*")) + return new[] { Permission.Wildcard }; + + if (selected.Count == catalog.Count() && catalog.All(selected.Contains)) + { + return new[] { Permission.Wildcard }; + } + + var result = new HashSet(); + + var catalogGroups = catalog.GroupBy(p => p.Split('.')[0]); + + foreach (var group in catalogGroups) + { + var prefix = group.Key; + + var allPermissions = group.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selectedInGroup = selected + .Where(p => p.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (selectedInGroup.SetEquals(allPermissions)) + { + result.Add(prefix + ".*"); + } + else + { + result.UnionWith(selectedInGroup); + } + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs new file mode 100644 index 00000000..b3a0b59a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class UAuthPermissionCatalog +{ + public static IReadOnlyList GetAll() + { + var result = new List(); + Collect(typeof(UAuthActions), result); + return result; + } + + public static IReadOnlyList GetAdminPermissions() + { + return GetAll() + .Where(x => x.EndsWith(".admin", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static void Collect(Type type, List result) + { + foreach (var field in type.GetFields( + BindingFlags.Public | + BindingFlags.Static | + BindingFlags.FlattenHierarchy)) + { + if (field.IsLiteral && field.FieldType == typeof(string)) + { + result.Add((string)field.GetValue(null)!); + } + } + + foreach (var nested in type.GetNestedTypes()) + { + Collect(nested, result); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs index c1f264b0..b008663c 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -15,7 +15,7 @@ public sealed class Role : IVersionedEntity, IEntitySnapshot, ISoftDeletab public string Name { get; private set; } = default!; public string NormalizedName { get; private set; } = default!; - public IReadOnlyCollection Permissions => _permissions.ToArray(); + public IReadOnlyCollection Permissions => _permissions; public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; private set; } @@ -90,8 +90,9 @@ public Role RemovePermission(Permission permission, DateTimeOffset now) public Role SetPermissions(IEnumerable permissions, DateTimeOffset now) { _permissions.Clear(); + var normalized = PermissionNormalizer.Normalize(permissions, UAuthPermissionCatalog.GetAdminPermissions()); - foreach (var p in permissions) + foreach (var p in normalized) _permissions.Add(p); UpdatedAt = now; From cb4d97959567673b593f7b5dcafd320568a96846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 13 Mar 2026 00:40:05 +0300 Subject: [PATCH 27/29] UAuthStateView Enhancement --- .../Components/Dialogs/PermissionDialog.razor | 13 +- .../Components/Dialogs/RoleDialog.razor | 6 +- .../Components/Pages/Home.razor | 1 + .../Components/Pages/Home.razor.cs | 12 ++ .../Components/Pages/LandingPage.razor.cs | 2 +- .../AuthState/UAuthState.cs | 31 ++++- .../Components/UALoginDispatch.razor | 2 +- .../Components/UAuthLoginForm.razor | 2 +- .../Components/UAuthLoginForm.razor.cs | 2 +- .../Components/UAuthStateView.razor | 33 ++--- .../Components/UAuthStateView.razor.cs | 113 +++++++++++++++++- .../Services/UAuthAuthorizationClient.cs | 9 +- .../Defaults/UAuthActions.cs | 14 +++ .../{Constants => Defaults}/UAuthConstants.cs | 7 +- .../Defaults/UAuthSchemeDefaults.cs | 6 - .../Extensions/ClaimsSnapshotExtensions.cs | 3 +- .../Auth/ClientProfileReader.cs | 2 +- .../UAuthAuthenticationExtension.cs | 2 +- .../AspNetCore/UAuthAuthenticationHandler.cs | 5 +- .../HttpContextReturnUrlExtensions.cs | 2 +- .../HttpContextSessionExtensions.cs | 4 +- .../HttpContextTenantExtensions.cs | 2 +- .../HttpContext/HttpContextUserExtensions.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Flows/Refresh/RefreshResponseWriter.cs | 2 +- .../Infrastructure/Device/DeviceResolver.cs | 4 +- .../Orchestrator/UAuthAccessOrchestrator.cs | 2 +- .../Session/SessionContextAccessor.cs | 4 +- .../User/HttpContextCurrentUser.cs | 2 +- .../Infrastructure/User/UAuthUserAccessor.cs | 2 +- .../SessionResolutionMiddleware.cs | 4 +- .../Middlewares/TenantMiddleware.cs | 2 +- .../AuthorizationClaimsProvider.cs | 2 +- .../Policies/MustHavePermissionPolicy.cs | 2 +- .../Helpers/TestHttpContext.cs | 2 +- .../Server/RedirectTests.cs | 2 +- 36 files changed, 242 insertions(+), 71 deletions(-) rename src/CodeBeam.UltimateAuth.Core/{Constants => Defaults}/UAuthConstants.cs (88%) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor index 9f89c886..77cf7c13 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -109,15 +109,20 @@ Permissions = permissions }; - var res = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); + var result = await UAuthClient.Authorization.SetPermissionsAsync(Role.Id, req); - if (!res.IsSuccess) + if (!result.IsSuccess) { - Snackbar.Add(res.Problem?.Title ?? "Failed to update permissions", Severity.Error); + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to update permissions", Severity.Error); return; } - Role = (await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name })).Value!.Items.First(); + var result2 = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name }); + if (result2.Value?.Items is not null) + { + Role = result2.Value.Items.First(); + } + Snackbar.Add("Permissions updated", Severity.Success); RefreshUI(); } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor index 540bc7ae..0a44b8b9 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -13,7 +13,7 @@ + EditMode="DataGridEditMode.Form" CommittedItemChanges="CommittedItemChanges" Loading="_loading" ReadOnly="false"> @@ -83,7 +83,6 @@ @code { private MudDataGrid? _grid; private bool _loading; - private bool _reloadQueued; private string? _newRoleName; [CascadingParameter] @@ -219,10 +218,13 @@ private async Task ReloadAsync() { + _loading = true; + await Task.Delay(300); if (_grid is null) return; await _grid.ReloadServerData(); + _loading = false; } private int GetPermissionCount(RoleInfo role) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 6cd64562..a78c2008 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -9,6 +9,7 @@ @inject IDialogService DialogService @using System.Security.Claims @using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults @using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Custom @if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 85e19dcf..79e99c51 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -217,6 +217,18 @@ private async Task SetAccountActiveAsync() } } + private string? _roles = "Admin"; + private void RefreshHiddenState() + { + if (_roles == "Admin") + { + _roles = "User"; + return; + } + + _roles = "Admin"; + } + public override void Dispose() { base.Dispose(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs index 41a7e106..ab16ba5e 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; diff --git a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index 0dd58c6e..cbc32fbe 100644 --- a/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs +++ b/src/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -40,6 +42,8 @@ internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validated Identity = snapshot.Identity; Claims = snapshot.Claims; + _compiledPermissions = new CompiledPermissionSet(Claims.Permissions.Select(Permission.From)); + IsStale = false; LastValidatedAt = validatedAt; @@ -114,7 +118,28 @@ internal void Clear() public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role); - public bool HasPermission(string permission) => IsAuthenticated && Claims.HasPermission(permission); + private CompiledPermissionSet? _compiledPermissions; + public bool HasPermission(string permission) + { + if (!IsAuthenticated) + return false; + + if (Claims.HasPermission(permission)) + return true; + + return _compiledPermissions?.IsAllowed(permission) == true; + } + + public bool HasAnyPermission(params string[] permissions) + { + foreach (var perm in permissions) + { + if (HasPermission(perm)) + return true; + } + + return false; + } public bool HasClaim(string type, string value) => IsAuthenticated && Claims.HasValue(type, value); @@ -123,7 +148,7 @@ internal void Clear() /// /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. /// - public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) { if (!IsAuthenticated || Identity is null) return new ClaimsPrincipal(new ClaimsIdentity()); diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor index f2b7a79d..d4942ba9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UALoginDispatch.razor @@ -1,7 +1,7 @@ @page "/__uauth/login-redirect" @namespace CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Core.Constants +@using CodeBeam.UltimateAuth.Core.Defaults @using Microsoft.AspNetCore.WebUtilities @inject NavigationManager Nav diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor index 75273832..d8d4dddf 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor @@ -4,7 +4,7 @@ @using CodeBeam.UltimateAuth.Client.Device @using CodeBeam.UltimateAuth.Client.Options @using CodeBeam.UltimateAuth.Core.Abstractions -@using CodeBeam.UltimateAuth.Core.Constants +@using CodeBeam.UltimateAuth.Core.Defaults @using CodeBeam.UltimateAuth.Core.Contracts @using CodeBeam.UltimateAuth.Core.Options @using Microsoft.Extensions.Options diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs index a820233d..cd7de6dc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthLoginForm.razor.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor index 95894528..bbcbab52 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor @@ -2,7 +2,9 @@ @inherits UAuthReactiveComponentBase @using CodeBeam.UltimateAuth.Core.Domain +@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@inject IAuthorizationService AuthorizationService @if (_inactive) { @@ -15,21 +17,22 @@ @NotAuthorized } } +else if (_authorizing && Authorizing is not null) +{ + @Authorizing +} +else if (!_authorized) +{ + @NotAuthorized +} else { - - - @if (Authorized is not null) - { - @Authorized(AuthState) - } - else if (ChildContent is not null) - { - @ChildContent(AuthState) - } - - - @NotAuthorized - - + if (Authorized is not null) + { + @Authorized(AuthState) + } + else if (ChildContent is not null) + { + @ChildContent(AuthState) + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs index 824f7107..23dc02e9 100644 --- a/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs +++ b/src/CodeBeam.UltimateAuth.Client/Components/UAuthStateView.razor.cs @@ -1,10 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; namespace CodeBeam.UltimateAuth.Client; public partial class UAuthStateView : UAuthReactiveComponentBase { + private IReadOnlyList _rolesParsed = Array.Empty(); + private IReadOnlyList _permissionsParsed = Array.Empty(); + private bool _authorized; + private bool _inactive; + private bool _authorizing; + private string? _authKey; + private string? _rolesRaw; + private string? _permissionsRaw; + [Parameter] public RenderFragment? Authorized { get; set; } @@ -14,29 +24,94 @@ public partial class UAuthStateView : UAuthReactiveComponentBase [Parameter] public RenderFragment? Inactive { get; set; } + [Parameter] + public RenderFragment? Authorizing { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public string? Roles { get; set; } + [Parameter] + public string? Permissions { get; set; } + [Parameter] public string? Policy { get; set; } + /// + /// Gets or sets a value indicating whether all set conditions must be matched for the operation to succeed. + /// Null parameters don't count as condition. + /// [Parameter] - public bool RequireActive { get; set; } = true; + public bool MatchAll { get; set; } = true; - private bool _inactive; + [Parameter] + public bool RequireActive { get; set; } = true; - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { - base.OnParametersSet(); + await base.OnParametersSetAsync(); + + var newKey = BuildAuthKey(); + + if (_authKey == newKey) + return; + + _authKey = newKey; + _authorizing = true; + + if (_rolesRaw != Roles) + { + _rolesRaw = Roles; + _rolesParsed = ParseCsv(Roles); + } + + if (_permissionsRaw != Permissions) + { + _permissionsRaw = Permissions; + _permissionsParsed = ParseCsv(Permissions); + } + EvaluateSessionState(); + _authorized = await EvaluateAuthorizationAsync(); + _authorizing = false; } - protected override void HandleAuthStateChanged(UAuthStateChangeReason reason) + protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason) { EvaluateSessionState(); + _authorizing = true; + _authorized = await EvaluateAuthorizationAsync(); + _authorizing = false; + await InvokeAsync(StateHasChanged); + } + + private async Task EvaluateAuthorizationAsync() + { + if (!AuthState.IsAuthenticated) + return false; + + var roles = _rolesParsed; + var permissions = _permissionsParsed; + + var results = new List(); + + if (roles.Count > 0) + results.Add(roles.Any(AuthState.IsInRole)); + + if (permissions.Count > 0) + results.Add(permissions.Any(AuthState.HasPermission)); + + if (!string.IsNullOrWhiteSpace(Policy)) + results.Add(await EvaluatePolicyAsync()); + + if (results.Count == 0) + return true; + + return MatchAll + ? results.All(x => x) + : results.Any(x => x); } private void EvaluateSessionState() @@ -62,4 +137,32 @@ private void EvaluateSessionState() _inactive = AuthState.Identity?.SessionState != SessionState.Active; } } + + private static IReadOnlyList ParseCsv(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return Array.Empty(); + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToArray(); + } + + private async Task EvaluatePolicyAsync() + { + if (string.IsNullOrWhiteSpace(Policy)) + return true; + + var principal = AuthState.ToClaimsPrincipal(); + var result = await AuthorizationService.AuthorizeAsync(principal, Policy); + + return result.Succeeded; + } + + private string BuildAuthKey() + { + return $"{Roles}|{Permissions}|{Policy}|{MatchAll}"; + } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 255e35ad..342aa7da 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -84,7 +84,14 @@ public async Task>> QueryRolesAsync(RoleQuery public async Task RenameRoleAsync(RoleId roleId, RenameRoleRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{roleId}/rename"), request); - return UAuthResultMapper.From(raw); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; } public async Task SetPermissionsAsync(RoleId roleId, SetPermissionsRequest request) diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 49a86c51..ca722e18 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -21,6 +21,8 @@ public static string Create(string resource, string operation, ActionScope scope public static class Flows { + public const string Wildcard = "flows.*"; + public const string LogoutSelf = "flows.logout.self"; public const string LogoutDeviceSelf = "flows.logoutdevice.self"; public const string LogoutDeviceAdmin = "flows.logoutdevice.admin"; @@ -32,6 +34,8 @@ public static class Flows public static class Sessions { + public const string Wildcard = "sessions.*"; + public const string GetChainSelf = "sessions.getchain.self"; public const string GetChainAdmin = "sessions.getchain.admin"; public const string ListChainsSelf = "sessions.listchains.self"; @@ -47,6 +51,8 @@ public static class Sessions public static class Users { + public const string Wildcard = "users.*"; + public const string CreateAnonymous = "users.create.anonymous"; public const string CreateAdmin = "users.create.admin"; public const string DeleteSelf = "users.delete.self"; @@ -57,6 +63,8 @@ public static class Users public static class UserProfiles { + public const string Wildcard = "users.profile.*"; + public const string GetSelf = "users.profile.get.self"; public const string UpdateSelf = "users.profile.update.self"; public const string GetAdmin = "users.profile.get.admin"; @@ -65,6 +73,8 @@ public static class UserProfiles public static class UserIdentifiers { + public const string Wildcard = "users.identifiers.*"; + public const string GetSelf = "users.identifiers.get.self"; public const string GetAdmin = "users.identifiers.get.admin"; public const string AddSelf = "users.identifiers.add.self"; @@ -83,6 +93,8 @@ public static class UserIdentifiers public static class Credentials { + public const string Wildcard = "credentials.*"; + public const string ListSelf = "credentials.list.self"; public const string ListAdmin = "credentials.list.admin"; public const string AddSelf = "credentials.add.self"; @@ -101,6 +113,8 @@ public static class Credentials public static class Authorization { + public const string Wildcard = "authorization.*"; + public static class Roles { public const string ReadSelf = "authorization.roles.read.self"; diff --git a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs similarity index 88% rename from src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs rename to src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs index 9906aec8..0a9418b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Constants/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -1,7 +1,12 @@ -namespace CodeBeam.UltimateAuth.Core.Constants; +namespace CodeBeam.UltimateAuth.Core.Defaults; public static class UAuthConstants { + public static class SchemeDefaults + { + public const string GlobalScheme = "UltimateAuth"; + } + public static class Access { public const string Permissions = "permissions"; diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs deleted file mode 100644 index 98c5d1fb..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthSchemeDefaults.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Defaults; - -public static class UAuthSchemeDefaults -{ - public const string AuthenticationScheme = "UltimateAuth"; -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs index 51485596..49ec65db 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using System.Security.Claims; @@ -9,7 +10,7 @@ public static class ClaimsSnapshotExtensions /// /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. /// - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) { if (snapshot == null) return new ClaimsPrincipal(new ClaimsIdentity()); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs index 6cbb1fad..067810f4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs index 5a8e66ff..eabcc607 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -7,7 +7,7 @@ public static class UAuthAuthenticationExtensions { public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder, Action? configure = null) { - return builder.AddScheme(UAuthSchemeDefaults.AuthenticationScheme, + return builder.AddScheme(UAuthConstants.SchemeDefaults.GlobalScheme, options => { configure?.Invoke(options); diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index 716f01d0..bb5fded8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; @@ -73,8 +72,8 @@ protected override async Task HandleAuthenticateAsync() if (snapshot is null || snapshot.Identity is null) return AuthenticateResult.NoResult(); - var principal = snapshot.ToClaimsPrincipal(UAuthSchemeDefaults.AuthenticationScheme); - return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthSchemeDefaults.AuthenticationScheme)); + var principal = snapshot.ToClaimsPrincipal(UAuthConstants.SchemeDefaults.GlobalScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthConstants.SchemeDefaults.GlobalScheme)); } protected override Task HandleChallengeAsync(AuthenticationProperties properties) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs index d664f3a1..890cdf28 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs index 08338fc3..3cf61e3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs index d1b57a00..69c58757 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs index 9936b9e6..ac88c5d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 4b94858b..b4646a45 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -237,9 +237,9 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.PostConfigureAll(options => { - options.DefaultAuthenticateScheme ??= UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultSignInScheme ??= UAuthSchemeDefaults.AuthenticationScheme; - options.DefaultChallengeScheme ??= UAuthSchemeDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultSignInScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultChallengeScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; }); services.AddAuthentication().AddUAuthCookies(); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs index 8ff47bc9..324d70ec 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 20022912..dbcc0a50 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Abstractions; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 26b1e085..0cb49e07 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -1,8 +1,8 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Policies.Abstractions; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs index 4c6764dc..da93972b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Infrastructure; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs index 6b933a91..64664ca5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs index 52d649b4..d02e74b3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Middlewares; diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 13929078..7e7d94d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -1,5 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Constants; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index fef8b200..719f90b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.MultiTenancy; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs index 94b705cb..cc427cdd 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs @@ -1,5 +1,5 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Security.Claims; diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs index 59a01b92..0e5814ec 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; namespace CodeBeam.UltimateAuth.Authorization.Policies; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs index 4545b326..1e478c79 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Constants; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using Microsoft.AspNetCore.Http; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs index 13180acb..cd72bded 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Constants; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; From f3b0eb29a75ad6e0d67680d67c9f0022b20b4ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 13 Mar 2026 01:34:19 +0300 Subject: [PATCH 28/29] Completed User Role Management --- .../Components/Dialogs/UserRoleDialog.razor | 155 ++++++++++++++++++ .../Components/Pages/Home.razor | 4 +- .../Components/Pages/Home.razor.cs | 7 + .../Abstractions/IAuthorizationClient.cs | 4 +- .../Services/UAuthAuthorizationClient.cs | 19 ++- .../Contracts/Common/UAuthResult.cs | 2 + .../Stores/InMemoryUserRoleStore.cs | 3 +- 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor new file mode 100644 index 00000000..2e38f8be --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor @@ -0,0 +1,155 @@ +@using CodeBeam.UltimateAuth.Authorization.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + User Roles + UserKey: @UserKey.Value + + + + + Assigned Roles + + @if (_roles.Count == 0) + { + No roles assigned + } + + + @foreach (var role in _roles) + { + @role + } + + + + + Add Role + + + + @foreach (var role in _allRoles) + { + @role.Name + } + + + Add + + + + + + Close + + + +@code { + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } = default!; + + private List _roles = new(); + private List _allRoles = new(); + + private string? _selectedRole; + + protected override async Task OnInitializedAsync() + { + await LoadRoles(); + } + + private async Task LoadRoles() + { + var userRoles = await UAuthClient.Authorization.GetUserRolesAsync(UserKey); + + if (userRoles.IsSuccess && userRoles.Value != null) + _roles = userRoles.Value.Roles.Items.Select(x => x.Name).ToList(); + + var roles = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + { + PageNumber = 1, + PageSize = 200 + }); + + if (roles.IsSuccess && roles.Value != null) + _allRoles = roles.Value.Items.ToList(); + } + + private async Task AddRole() + { + if (string.IsNullOrWhiteSpace(_selectedRole)) + return; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(UserKey, _selectedRole); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + } + + _selectedRole = null; + } + + private async Task RemoveRole(string role) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Remove Role", + $"Remove {role} from user?", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + + if (role == "Admin") + { + var confirm2 = await DialogService.ShowMessageBoxAsync( + "Are You Sure", + "You are going to remove admin role. This action may cause the application unuseable.", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm2 != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + } + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(UserKey, role); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed", Severity.Success); + } + else + { + Snackbar.Add(result.GetErrorText ?? "Failed", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); + +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index a78c2008..a3634480 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -117,8 +117,8 @@
- - Assign Role + + User Role Management
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 79e99c51..71b7961f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -184,6 +184,13 @@ private async Task OpenRoleDialog() await DialogService.ShowAsync("Role Management", GetDialogParameters(), GetDialogOptions()); } + private async Task OpenUserRoleDialog() + { + var parameters = GetDialogParameters(); + parameters.Add("UserKey", AuthState.Identity.UserKey); + await DialogService.ShowAsync("Role Management", parameters, GetDialogOptions()); + } + private DialogOptions GetDialogOptions() { return new DialogOptions diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs index ba16baee..6e45c78c 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -8,8 +8,8 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IAuthorizationClient { Task> CheckAsync(AuthorizationCheckRequest request); - Task> GetMyRolesAsync(); - Task> GetUserRolesAsync(UserKey userKey); + Task> GetMyRolesAsync(PageRequest? request = null); + Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); Task AssignRoleToUserAsync(UserKey userKey, string role); Task RemoveRoleFromUserAsync(UserKey userKey, string role); diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 342aa7da..44cc7a53 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -30,15 +30,17 @@ public async Task> CheckAsync(AuthorizationChec return UAuthResultMapper.FromJson(raw); } - public async Task> GetMyRolesAsync() + public async Task> GetMyRolesAsync(PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/authorization/users/me/roles/get")); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/authorization/users/me/roles/get"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> GetUserRolesAsync(UserKey userKey) + public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/authorization/users/{userKey}/roles/get")); + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey}/roles/get"), request); return UAuthResultMapper.FromJson(raw); } @@ -49,7 +51,14 @@ public async Task AssignRoleToUserAsync(UserKey userKey, string rol Role = role }); - return UAuthResultMapper.From(raw); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; } public async Task RemoveRoleFromUserAsync(UserKey userKey, string role) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index c446070c..e1bf2aaa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -11,6 +11,8 @@ public class UAuthResult public HttpStatusInfo Http => new(Status); + public string? GetErrorText => Problem?.Detail ?? Problem?.Title; + public sealed class HttpStatusInfo { private readonly int _status; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index 6bcb7e71..64a4c958 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections.Concurrent; @@ -31,7 +32,7 @@ public Task AssignAsync(TenantKey tenant, UserKey userKey, RoleId roleId, DateTi lock (list) { if (list.Any(x => x.RoleId == roleId)) - return Task.CompletedTask; + throw new UAuthConflictException("Role is already assigned to the user."); list.Add(new UserRole { From 25a0c7c6c2daea74a2033d05f41c57557adc46f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 13 Mar 2026 19:41:03 +0300 Subject: [PATCH 29/29] Finalized Endpoints & Admin User Management Dialog --- .../Common/UAuthDialog.cs | 29 +++ .../Components/Dialogs/CredentialDialog.razor | 75 ++++-- .../Components/Dialogs/IdentifierDialog.razor | 15 +- .../Components/Dialogs/ResetDialog.razor | 2 + .../Components/Dialogs/UserDetailDialog.razor | 91 +++++++ .../Components/Dialogs/UserRoleDialog.razor | 1 - .../Components/Dialogs/UsersDialog.razor | 225 ++++++++++++++++++ .../Components/Pages/Home.razor | 11 +- .../Components/Pages/Home.razor.cs | 31 +-- .../Abstractions/ICredentialClient.cs | 14 +- .../Services/Abstractions/IUserClient.cs | 9 +- .../Services/UAuthAuthorizationClient.cs | 2 +- .../Services/UAuthCredentialClient.cs | 41 ++-- .../Services/UAuthUserClient.cs | 35 +-- .../Services/UAuthUserIdentifierClient.cs | 14 +- .../Defaults/UAuthActions.cs | 1 + .../Base/UAuthAuthorizationException.cs | 9 - .../Runtime/UAuthAuthorizationException.cs | 14 ++ .../ICredentialEndpointHandler.cs | 1 + .../Abstractions/IUserEndpointHandler.cs | 1 + .../Endpoints/UAuthEndpointRegistrar.cs | 95 ++++---- .../Orchestrator/UAuthAccessOrchestrator.cs | 4 +- .../Orchestrator/UAuthSessionOrchestrator.cs | 2 +- .../Options/UAuthServerEndpointOptions.cs | 4 +- .../Request/ChangeCredentialRequest.cs | 2 +- .../Endpoints/CredentialEndpointHandler.cs | 17 ++ .../Defaults/DefaultPolicySet.cs | 1 + .../DenyAdminSelfModificationPolicy.cs | 35 +++ .../Dtos/UserQuery.cs | 10 + .../Dtos/UserSummary.cs | 21 ++ .../Dtos/{UserViewDto.cs => UserView.cs} | 10 +- .../Requests/ChangeUserStatusAdminRequest.cs | 1 - .../Stores/InMemoryUserIdentifierStore.cs | 17 ++ .../Stores/InMemoryUserProfileStore.cs | 17 ++ .../Endpoints/UserEndpointHandler.cs | 19 ++ .../Mapping/UserProfileMapper.cs | 4 +- .../Services/IUserApplicationService.cs | 5 +- .../Services/UserApplicationService.cs | 130 +++++++++- .../Stores/IUserIdentifierStore.cs | 1 + .../Stores/IUserProfileStore.cs | 2 + 40 files changed, 825 insertions(+), 193 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor delete mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs create mode 100644 src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/{UserViewDto.cs => UserView.cs} (75%) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs new file mode 100644 index 00000000..58dae70b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Common; + +public static class UAuthDialog +{ + public static DialogParameters GetDialogParameters(UAuthState state, UserKey? userKey = null) + { + DialogParameters parameters = new DialogParameters(); + parameters.Add("AuthState", state); + if (userKey != null ) + { + parameters.Add("UserKey", userKey); + } + return parameters; + } + + public static DialogOptions GetDialogOptions(MaxWidth maxWidth = MaxWidth.Medium) + { + return new DialogOptions + { + MaxWidth = maxWidth, + FullWidth = true, + CloseButton = true + }; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor index 47436c7e..01c01ff0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -15,9 +15,21 @@ - - - + @if (UserKey == null) + { + + + + } + else + { + + + Administrators can directly assign passwords to users. + However, using the credential reset flow is generally recommended for better security and auditability. + + + } @@ -28,14 +40,13 @@ - Change Password + @(UserKey is null ? "Change Password" : "Set Password") Cancel - OK @@ -54,15 +65,8 @@ [Parameter] public UAuthState AuthState { get; set; } = default!; - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (firstRender) - { - - } - } + [Parameter] + public UserKey? UserKey { get; set; } private async Task ChangePasswordAsync() { @@ -75,35 +79,56 @@ Snackbar.Add("Form is not valid.", Severity.Error); return; } - + if (_newPassword != _newPasswordCheck) { Snackbar.Add("New password and check do not match", Severity.Error); return; } - ChangeCredentialRequest request = new ChangeCredentialRequest + ChangeCredentialRequest request; + + if (UserKey is null) { - CurrentSecret = _oldPassword!, - NewSecret = _newPassword!, - }; + request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword! + }; + } + else + { + request = new ChangeCredentialRequest + { + NewSecret = _newPassword! + }; + } - var result = await UAuthClient.Credentials.ChangeMyAsync(request); + UAuthResult result; + if (UserKey is null) + { + result = await UAuthClient.Credentials.ChangeMyAsync(request); + } + else + { + result = await UAuthClient.Credentials.ChangeCredentialAsync(UserKey.Value, request); + } + if (result.IsSuccess) { Snackbar.Add("Password changed successfully", Severity.Success); - // Logout also if you want. Password change only revoke other open sessions, not the current one. - // await UAuthClient.Flows.LogoutAsync(); + _oldPassword = null; + _newPassword = null; + _newPasswordCheck = null; + MudDialog.Close(DialogResult.Ok(true)); } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "An error occurred while changing password", Severity.Error); + Snackbar.Add(result.GetErrorText ?? "An error occurred while changing password", Severity.Error); } } private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; - private void Submit() => MudDialog.Close(DialogResult.Ok(true)); - private void Cancel() => MudDialog.Cancel(); } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor index dc5b27f1..918a660b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -81,9 +81,8 @@
- -
+ Cancel OK @@ -198,7 +197,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to update identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to update identifier", Severity.Error); } await ReloadAsync(); @@ -229,7 +228,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to add identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to add identifier", Severity.Error); } } @@ -262,7 +261,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to verify primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to verify primary identifier", Severity.Error); } } @@ -278,7 +277,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to set primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to set primary identifier", Severity.Error); } } @@ -294,7 +293,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to unset primary identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to unset primary identifier", Severity.Error); } } @@ -310,7 +309,7 @@ } else { - Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Failed to delete identifier", Severity.Error); + Snackbar.Add(result?.GetErrorText ?? "Failed to delete identifier", Severity.Error); } } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor index 2b94e413..1e909a9f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -11,6 +11,7 @@ Reset Credential + @@ -30,6 +31,7 @@ } + Cancel OK diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor new file mode 100644 index 00000000..8b279371 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor @@ -0,0 +1,91 @@ +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService + + + + User Management + @_user?.UserKey.Value + + + + + + User Info + UserKey: @_user?.UserKey.Value + Display Name: @_user?.DisplayName + Status: @_user?.Status + + + + + + @* Status *@ + Profile + Identifiers + Credentials + Roles + + + + + + Close + + + +@code { + private UserView? _user; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var result = await UAuthClient.Users.GetProfileAsync(UserKey); + _user = result.Value; + } + + private async Task OpenProfile() + { + // await DialogService.ShowAsync( + // "Profile", + // new DialogParameters + // { + // { nameof(UserProfileDialog.UserKey), User.UserKey } + // }); + } + + private async Task OpenIdentifiers() + { + // await DialogService.ShowAsync( + // "Identifiers", + // new DialogParameters + // { + // { nameof(UserIdentifiersDialog.UserKey), User.UserKey } + // }); + } + + private async Task OpenCredentials() + { + await DialogService.ShowAsync("Credentials", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenRoles() + { + await DialogService.ShowAsync("Roles", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private void Close() + { + MudDialog.Close(); + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor index 2e38f8be..9ae32dfa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor @@ -151,5 +151,4 @@ } private void Close() => MudDialog.Close(); - } \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor new file mode 100644 index 00000000..05880826 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor @@ -0,0 +1,225 @@ +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + Browse, create and manage users + + + + + + + + + + + + + + + + + + + + + + + @context.Item.Status + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.UserKey.Value + + + + Created At + @context.Item.CreatedAt + + + + + + + + + + + + + Close + + + +@code { + private MudDataGrid? _grid; + private bool _loading; + private string? _search; + private bool _reloadQueued; + private UserStatus? _statusFilter; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadUsers(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new UserQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + Search = _search, + Status = _statusFilter, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Users.QueryUsersAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.GetErrorText ?? "Failed to load users", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnStatusChanged(UserStatus? status) + { + _statusFilter = status; + await ReloadAsync(); + } + + private async Task OpenUser(UserKey userKey) + { + await DialogService.ShowAsync("User", UAuthDialog.GetDialogParameters(AuthState, userKey), UAuthDialog.GetDialogOptions()); + } + + private async Task DeleteUserAsync(UserSummary user) + { + var confirm = await DialogService.ShowMessageBoxAsync( + title: "Delete user", + markupMessage: (MarkupString)$""" + Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.PrimaryEmail ?? user.UserKey}? +

+ This operation is intended for admin usage. + """, + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirm != true) + return; + + var req = new DeleteUserRequest + { + Mode = DeleteMode.Soft + }; + + var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + + if (result.IsSuccess) + { + Snackbar.Add("User deleted successfully", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.GetErrorText ?? "Failed to delete user", Severity.Error); + } + } + + private static Color GetStatusColor(UserStatus status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.SelfSuspended => Color.Warning, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index a3634480..0518403b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -108,6 +108,12 @@ @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) { + + + User Management + + + @@ -116,11 +122,6 @@ - - - User Role Management - - } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 71b7961f..31e9c9a6 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Common; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components.Authorization; @@ -156,49 +157,37 @@ private string GetHealthText() private async Task OpenProfileDialog() { - await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), GetDialogOptions()); + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } private async Task OpenIdentifierDialog() { - await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), GetDialogOptions()); + await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } private async Task OpenSessionDialog() { - await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), GetDialogOptions()); + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } private async Task OpenCredentialDialog() { - await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), GetDialogOptions()); + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } private async Task OpenAccountStatusDialog() { - await DialogService.ShowAsync("Manage Account", GetDialogParameters(), GetDialogOptions()); + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } - private async Task OpenRoleDialog() - { - await DialogService.ShowAsync("Role Management", GetDialogParameters(), GetDialogOptions()); - } - - private async Task OpenUserRoleDialog() + private async Task OpenUserDialog() { - var parameters = GetDialogParameters(); - parameters.Add("UserKey", AuthState.Identity.UserKey); - await DialogService.ShowAsync("Role Management", parameters, GetDialogOptions()); + await DialogService.ShowAsync("User Management", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } - private DialogOptions GetDialogOptions() + private async Task OpenRoleDialog() { - return new DialogOptions - { - MaxWidth = MaxWidth.Medium, - FullWidth = true, - CloseButton = true - }; + await DialogService.ShowAsync("Role Management", GetDialogParameters(), UAuthDialog.GetDialogOptions()); } private DialogParameters GetDialogParameters() diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs index 534efb0a..1bd06422 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -6,18 +6,16 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface ICredentialClient { - Task> GetMyAsync(); Task> AddMyAsync(AddCredentialRequest request); Task> ChangeMyAsync(ChangeCredentialRequest request); Task RevokeMyAsync(RevokeCredentialRequest request); Task> BeginResetMyAsync(BeginCredentialResetRequest request); Task> CompleteResetMyAsync(CompleteCredentialResetRequest request); - Task> GetUserAsync(UserKey userKey); - Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); - Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); - Task ActivateUserAsync(UserKey userKey); - Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request); - Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request); - Task DeleteUserAsync(UserKey userKey); + Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request); + Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request); + Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request); + Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request); + Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request); + Task DeleteCredentialAsync(UserKey userKey); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 5c23dfe5..f421a712 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -6,15 +6,16 @@ namespace CodeBeam.UltimateAuth.Client.Services; public interface IUserClient { + Task>> QueryUsersAsync(UserQuery query); Task> CreateAsync(CreateUserRequest request); Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); - Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); + Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest request); Task DeleteMeAsync(); - Task> DeleteAsync(DeleteUserRequest request); + Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(); Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetProfileAsync(UserKey userKey); + Task> GetProfileAsync(UserKey userKey); Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 44cc7a53..683db2bc 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -33,7 +33,7 @@ public async Task> CheckAsync(AuthorizationChec public async Task> GetMyRolesAsync(PageRequest? request = null) { request ??= new PageRequest(); - var raw = await _request.SendJsonAsync(Url("/authorization/users/me/roles/get"), request); + var raw = await _request.SendJsonAsync(Url("/me/authorization/roles/get"), request); return UAuthResultMapper.FromJson(raw); } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 63aed3cc..ca73037a 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -23,21 +23,15 @@ public UAuthCredentialClient(IUAuthRequestClient request, IUAuthClientEvents eve private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task> GetMyAsync() - { - var raw = await _request.SendFormAsync(Url("/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - public async Task> AddMyAsync(AddCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); + var raw = await _request.SendJsonAsync(Url("/me/credentials/add"), request); return UAuthResultMapper.FromJson(raw); } public async Task> ChangeMyAsync(ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/change"), request); + var raw = await _request.SendJsonAsync(Url("/me/credentials/change"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.StateEvents.HandlingMode)); @@ -47,7 +41,7 @@ public async Task> ChangeMyAsync(ChangeCrede public async Task RevokeMyAsync(RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/revoke"), request); + var raw = await _request.SendJsonAsync(Url($"/me/credentials/revoke"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); @@ -57,13 +51,13 @@ public async Task RevokeMyAsync(RevokeCredentialRequest request) public async Task> BeginResetMyAsync(BeginCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/reset/begin"), request); + var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/begin"), request); return UAuthResultMapper.FromJson(raw); } public async Task> CompleteResetMyAsync(CompleteCredentialResetRequest request) { - var raw = await _request.SendJsonAsync(Url($"/credentials/reset/complete"), request); + var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/complete"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); @@ -72,46 +66,39 @@ public async Task> CompleteResetMyAsync(Comp } - public async Task> GetUserAsync(UserKey userKey) - { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/get")); - return UAuthResultMapper.FromJson(raw); - } - - public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + public async Task> AddCredentialAsync(UserKey userKey, AddCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); return UAuthResultMapper.FromJson(raw); } - public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) + public async Task> ChangeCredentialAsync(UserKey userKey, ChangeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); - return UAuthResultMapper.From(raw); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/change"), request); + return UAuthResultMapper.FromJson(raw); } - public async Task ActivateUserAsync(UserKey userKey) + public async Task RevokeCredentialAsync(UserKey userKey, RevokeCredentialRequest request) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/activate")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/revoke"), request); return UAuthResultMapper.From(raw); } - public async Task> BeginResetUserAsync(UserKey userKey, BeginCredentialResetRequest request) + public async Task> BeginResetCredentialAsync(UserKey userKey, BeginCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/begin"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> CompleteResetUserAsync(UserKey userKey, CompleteCredentialResetRequest request) + public async Task> CompleteResetCredentialAsync(UserKey userKey, CompleteCredentialResetRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/reset/complete"), request); return UAuthResultMapper.FromJson(raw); } - public async Task DeleteUserAsync(UserKey userKey) + public async Task DeleteCredentialAsync(UserKey userKey) { var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/delete")); return UAuthResultMapper.From(raw); } - } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 9fd9fb09..2cc21a81 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -23,15 +23,15 @@ public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, I private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task> GetMeAsync() + public async Task> GetMeAsync() { - var raw = await _request.SendFormAsync(Url("/users/me/get")); - return UAuthResultMapper.FromJson(raw); + var raw = await _request.SendFormAsync(Url("/me/get")); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/update"), request); + var raw = await _request.SendJsonAsync(Url("/me/update"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); @@ -41,7 +41,7 @@ public async Task UpdateMeAsync(UpdateProfileRequest request) public async Task DeleteMeAsync() { - var raw = await _request.SendJsonAsync(Url("/users/me/delete")); + var raw = await _request.SendJsonAsync(Url("/me/delete")); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); @@ -49,6 +49,13 @@ public async Task DeleteMeAsync() return UAuthResultMapper.From(raw); } + public async Task>> QueryUsersAsync(UserQuery query) + { + query ??= new UserQuery(); + var raw = await _request.SendJsonAsync(Url("/admin/users/query"), query); + return UAuthResultMapper.FromJson>(raw); + } + public async Task> CreateAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/users/create"), request); @@ -57,7 +64,7 @@ public async Task> CreateAsync(CreateUserRequest r public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/status"), request); + var raw = await _request.SendJsonAsync(Url("/me/status"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); @@ -65,27 +72,27 @@ public async Task> ChangeStatusSelfAsync(Cha return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) + public async Task> ChangeStatusAdminAsync(UserKey userKey, ChangeUserStatusAdminRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{request.UserKey.Value}/status"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/status"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> DeleteAsync(DeleteUserRequest request) + public async Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/delete")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/delete"), request); return UAuthResultMapper.FromJson(raw); } - public async Task> GetProfileAsync(UserKey userKey) + public async Task> GetProfileAsync(UserKey userKey) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/profile/get")); - return UAuthResultMapper.FromJson(raw); + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/profile/get")); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/profile/update"), request); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/update"), request); return UAuthResultMapper.From(raw); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index b4ccfa64..9f047dc3 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -26,13 +26,13 @@ public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents public async Task>> GetMyIdentifiersAsync(PageRequest? request = null) { request ??= new PageRequest(); - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/get"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/get"), request); return UAuthResultMapper.FromJson>(raw); } public async Task AddSelfAsync(AddUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/add"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/add"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); @@ -42,7 +42,7 @@ public async Task AddSelfAsync(AddUserIdentifierRequest request) public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/update"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/update"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); @@ -52,7 +52,7 @@ public async Task UpdateSelfAsync(UpdateUserIdentifierRequest reque public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/set-primary"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/set-primary"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); @@ -62,7 +62,7 @@ public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierReque public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/unset-primary"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/unset-primary"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); @@ -72,7 +72,7 @@ public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierR public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/verify"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/verify"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); @@ -82,7 +82,7 @@ public async Task VerifySelfAsync(VerifyUserIdentifierRequest reque public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/me/identifiers/delete"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/delete"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index ca722e18..faf1dc22 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -53,6 +53,7 @@ public static class Users { public const string Wildcard = "users.*"; + public const string QueryAdmin = "users.query.admin"; public const string CreateAnonymous = "users.create.anonymous"; public const string CreateAdmin = "users.create.admin"; public const string DeleteSelf = "users.delete.self"; diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs deleted file mode 100644 index b4f1ad19..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors; - -public sealed class UAuthAuthorizationException : UAuthRuntimeException -{ - public UAuthAuthorizationException(string? reason = null) - : base(code: "forbidden", message: reason ?? "The current principal is not authorized to perform this operation.") - { - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs new file mode 100644 index 00000000..c5ac0527 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthAuthorizationException : UAuthRuntimeException +{ + public override int StatusCode => 403; + + public override string Title => "Forbidden"; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/authorization"; + + public UAuthAuthorizationException(string code = "You do not have permission to perform this action.") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs index 50a318d2..1e766bf0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -14,6 +14,7 @@ public interface ICredentialEndpointHandler Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); Task AddAdminAsync(UserKey userKey, HttpContext ctx); + Task ChangeSecretAdminAsync(UserKey userKey, HttpContext ctx); Task RevokeAdminAsync(UserKey userKey, HttpContext ctx); Task DeleteAdminAsync(UserKey userKey, HttpContext ctx); Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index df97b972..5c915e66 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints; public interface IUserEndpointHandler { + Task QueryUsersAsync(HttpContext ctx); Task CreateAsync(HttpContext ctx); Task ChangeStatusSelfAsync(HttpContext ctx); Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 14604617..3c1186cb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -40,10 +40,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.AddEndpointFilter(); //var user = group.MapGroup(""); - var users = group.MapGroup("/users"); - var adminUsers = group.MapGroup("/admin/users"); + var self = group.MapGroup("/me"); + var admin = group.MapGroup("/admin"); - if (options.Endpoints.Login != false) + if (options.Endpoints.Authentication != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); @@ -75,15 +75,15 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) - adminUsers.MapPost("/logout-device/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + admin.MapPost("/users/logout-device/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); if (Enabled(UAuthActions.Flows.LogoutOthersAdmin)) - adminUsers.MapPost("/logout-others/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + admin.MapPost("/users/logout-others/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); if (Enabled(UAuthActions.Flows.LogoutAllAdmin)) - adminUsers.MapPost("/logout-all/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + admin.MapPost("/users/logout-all/{userKey}", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); } @@ -117,51 +117,52 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.Session != false) { - var session = group.MapGroup("/session"); + var selfSession = self.MapGroup("/sessions"); + var adminSession = admin.MapGroup("/users/{userKey}/sessions"); - if(Enabled(UAuthActions.Sessions.ListChainsSelf)) - session.MapPost("/me/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + if (Enabled(UAuthActions.Sessions.ListChainsSelf)) + selfSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); if (Enabled(UAuthActions.Sessions.GetChainSelf)) - session.MapPost("/me/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + selfSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); if (Enabled(UAuthActions.Sessions.RevokeChainSelf)) - session.MapPost("/me/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + selfSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.RevokeOtherChainsSelf)) - session.MapPost("/me/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + selfSession.MapPost("/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.RevokeAllChainsSelf)) - session.MapPost("/me/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + selfSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.ListChainsAdmin)) - adminUsers.MapPost("/{userKey}/sessions/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + adminSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); if (Enabled(UAuthActions.Sessions.GetChainAdmin)) - adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + adminSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); if (Enabled(UAuthActions.Sessions.RevokeSessionAdmin)) - adminUsers.MapPost("/{userKey}/sessions/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + adminSession.MapPost("/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.RevokeChainAdmin)) - adminUsers.MapPost("/{userKey}/sessions/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + adminSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.RevokeRootAdmin)) - adminUsers.MapPost("/{userKey}/sessions/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + adminSession.MapPost("/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); if (Enabled(UAuthActions.Sessions.RevokeAllChainsAdmin)) - adminUsers.MapPost("/{userKey}/sessions/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + adminSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } @@ -177,21 +178,27 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); //} + var adminUsers = admin.MapGroup("/users"); + if (options.Endpoints.UserLifecycle != false) { if (Enabled(UAuthActions.Users.CreateAnonymous)) - users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + group.MapPost("/users/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); if (Enabled(UAuthActions.Users.ChangeStatusSelf)) - users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); if (Enabled(UAuthActions.Users.DeleteSelf)) - users.MapPost("/me/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + if (Enabled(UAuthActions.Users.QueryAdmin)) + adminUsers.MapPost("/query", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); @@ -204,11 +211,11 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.UserProfile != false) { if (Enabled(UAuthActions.UserProfiles.GetSelf)) - users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) - users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); @@ -224,31 +231,31 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.UserIdentifier != false) { if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) - users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) - users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) - users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.SetPrimarySelf)) - users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimarySelf)) - users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) - users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) - users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); @@ -283,27 +290,27 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.Credentials != false) { - var credentials = group.MapGroup("/credentials"); - var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); + var selfCredentials = self.MapGroup("/credentials"); + var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); if (Enabled(UAuthActions.Credentials.AddSelf)) - credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); if (Enabled(UAuthActions.Credentials.ChangeSelf)) - credentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + selfCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); if (Enabled(UAuthActions.Credentials.RevokeSelf)) - credentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + selfCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); if (Enabled(UAuthActions.Credentials.BeginResetAnonymous)) - credentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + selfCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); if (Enabled(UAuthActions.Credentials.CompleteResetAnonymous)) - credentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + selfCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); @@ -311,6 +318,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.ChangeAdmin)) + adminCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + if (Enabled(UAuthActions.Credentials.RevokeAdmin)) adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); @@ -330,15 +341,15 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.Authorization != false) { - var authz = group.MapGroup("/authorization"); - var adminAuthz = group.MapGroup("/admin/authorization"); + var selfAuthz = self.MapGroup("/authorization"); + var adminAuthz = admin.MapGroup("/authorization"); // TODO: Add enabled actions here - authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); if (Enabled(UAuthActions.Authorization.Roles.ReadSelf)) - authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + selfAuthz.MapPost("/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 0cb49e07..6fe26b2d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -31,7 +31,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var decision = _authority.Decide(context, policies); if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason); + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); if (decision.RequiresReauthentication) throw new InvalidOperationException("Requires reauthentication."); @@ -49,7 +49,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessC var decision = _authority.Decide(context, policies); if (!decision.IsAllowed) - throw new UAuthAuthorizationException(decision.DenyReason); + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); if (decision.RequiresReauthentication) throw new InvalidOperationException("Requires reauthentication."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index e541fb7e..190cb521 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -28,7 +28,7 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi switch (decision.Decision) { case AuthorizationDecision.Deny: - throw new UAuthAuthorizationException(decision.Reason); + throw new UAuthAuthorizationException(decision.Reason ?? "authorization_denied"); case AuthorizationDecision.Challenge: throw new UAuthChallengeRequiredException(decision.Reason); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs index 45b677a3..ee34540f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -8,7 +8,7 @@ public sealed class UAuthServerEndpointOptions ///
public string BasePath { get; set; } = "/auth"; - public bool Login { get; set; } = true; + public bool Authentication { get; set; } = true; public bool Pkce { get; set; } = true; //public bool Token { get; set; } = true; public bool Session { get; set; } = true; @@ -27,7 +27,7 @@ public sealed class UAuthServerEndpointOptions internal UAuthServerEndpointOptions Clone() => new() { - Login = Login, + Authentication = Authentication, Pkce = Pkce, //Token = Token, Session = Session, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs index 7483584c..56928ebc 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -3,6 +3,6 @@ public sealed record ChangeCredentialRequest { public Guid Id { get; init; } - public required string CurrentSecret { get; init; } + public string? CurrentSecret { get; init; } public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index 76898d71..c95daf58 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -156,6 +156,23 @@ public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) return Results.Ok(result); } + public async Task ChangeSecretAdminAsync(UserKey userKey, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ChangeAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index fdf4e582..c7ccf270 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -16,6 +16,7 @@ public static void Register(AccessPolicyRegistry registry) // Intent-based registry.Add("", _ => new RequireSelfPolicy()); + registry.Add("", _ => new DenyAdminSelfModificationPolicy()); registry.Add("", _ => new RequireSystemPolicy()); // Permission diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs new file mode 100644 index 00000000..d2f682ee --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class DenyAdminSelfModificationPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (!context.IsAuthenticated) + return AccessDecision.Deny("unauthenticated"); + + if (context.ActorUserKey == context.TargetUserKey) + return AccessDecision.Deny("admin_cannot_modify_own_account"); + + return AccessDecision.Allow(); + } + + public bool AppliesTo(AccessContext context) + { + if (!context.Action.EndsWith(".admin")) + return false; + + if (context.TargetUserKey is null) + return false; + + // READ actions allowed + if (context.Action.Contains(".get") || + context.Action.Contains(".read") || + context.Action.Contains(".query")) + return false; + + return true; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs new file mode 100644 index 00000000..f3a44e63 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserQuery : PageRequest +{ + public string? Search { get; set; } + public UserStatus? Status { get; set; } + public bool IncludeDeleted { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs new file mode 100644 index 00000000..53763ee7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserSummary +{ + public UserKey UserKey { get; init; } = default!; + + public string? UserName { get; init; } + + public string? DisplayName { get; init; } + + public string? PrimaryEmail { get; init; } + + public string? PrimaryPhone { get; init; } + + public UserStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs similarity index 75% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs index 051da1bc..b245402d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs @@ -1,8 +1,12 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; -public sealed record UserViewDto +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserView { - public string UserKey { get; init; } = default!; + public UserKey UserKey { get; init; } = default!; + public UserStatus Status { get; init; } public string? UserName { get; init; } public string? PrimaryEmail { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index cb0d9521..ca0a6400 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -5,6 +5,5 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed class ChangeUserStatusAdminRequest { - public required UserKey UserKey { get; init; } public required UserStatus NewStatus { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index d898c5de..b99c7604 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -195,6 +195,23 @@ public Task> QueryAsync(TenantKey tenant, UserIdenti } + public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = userKeys.ToHashSet(); + + var result = Values() + .Where(x => x.Tenant == tenant) + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index dee140cf..d24ff036 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -65,4 +65,21 @@ public Task> QueryAsync(TenantKey tenant, UserProfileQu query.SortBy, query.Descending)); } + + public Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = userKeys.ToHashSet(); + + var result = Values() + .Where(x => x.Tenant == tenant) + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } } \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index a4fee3e5..acb99166 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -21,6 +21,25 @@ public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFact _users = users; } + public async Task QueryUsersAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var query = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.QueryAdmin, + resource: "users"); + + var result = await _users.QueryUsersAsync(accessContext, query, ctx.RequestAborted); + + return Results.Ok(result); + } + public async Task CreateAsync(HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index d1713b6b..e7a95f69 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -4,10 +4,10 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal static class UserProfileMapper { - public static UserViewDto ToDto(UserProfile profile) + public static UserView ToDto(UserProfile profile) => new() { - UserKey = profile.UserKey.ToString(), + UserKey = profile.UserKey, FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index fab67a4b..0bdb2192 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -5,9 +5,10 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserApplicationService { - Task GetMeAsync(AccessContext context, CancellationToken ct = default); - Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + Task GetMeAsync(AccessContext context, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 38634126..fbc7dce2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -246,9 +246,9 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque #region User Profile - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { if (context.ActorUserKey is null) throw new UnauthorizedAccessException(); @@ -259,9 +259,9 @@ public async Task GetMeAsync(AccessContext context, CancellationTok return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); @@ -639,12 +639,16 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #region Helpers - private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) { + var lifecycle = await _lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); var profile = await _profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); + if (lifecycle is null || lifecycle.IsDeleted) + throw new UAuthNotFoundException("user_not_found"); + if (profile is null || profile.IsDeleted) - throw new InvalidOperationException("user_profile_not_found"); + throw new UAuthNotFoundException("user_profile_not_found"); var identifiers = await _identifierStore.GetByUserAsync(tenant, userKey, ct); @@ -660,7 +664,8 @@ private async Task BuildUserViewAsync(TenantKey tenant, UserKey use PrimaryEmail = primaryEmail?.Value, PrimaryPhone = primaryPhone?.Value, EmailVerified = primaryEmail?.IsVerified ?? false, - PhoneVerified = primaryPhone?.IsVerified ?? false + PhoneVerified = primaryPhone?.IsVerified ?? false, + Status = lifecycle.Status }; } @@ -718,4 +723,115 @@ UserIdentifierType.Email or UserIdentifierType.Phone; #endregion + + public async Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + query ??= new UserQuery(); + + var lifecycleQuery = new UserLifecycleQuery + { + PageNumber = 1, + PageSize = int.MaxValue, + Status = query.Status, + IncludeDeleted = query.IncludeDeleted + }; + + var lifecycleResult = await _lifecycleStore.QueryAsync(context.ResourceTenant, lifecycleQuery, innerCt); + var lifecycles = lifecycleResult.Items; + + if (lifecycles.Count == 0) + { + return new PagedResult( + Array.Empty(), + 0, + query.PageNumber, + query.PageSize, + query.SortBy, + query.Descending); + } + + var userKeys = lifecycles.Select(x => x.UserKey).ToList(); + var profiles = await _profileStore.GetByUsersAsync(context.ResourceTenant, userKeys, innerCt); + var identifiers = await _identifierStore.GetByUsersAsync(context.ResourceTenant, userKeys, innerCt); + var profileMap = profiles.ToDictionary(x => x.UserKey); + var identifierGroups = identifiers.GroupBy(x => x.UserKey).ToDictionary(x => x.Key, x => x.ToList()); + + var summaries = new List(); + + foreach (var lifecycle in lifecycles) + { + profileMap.TryGetValue(lifecycle.UserKey, out var profile); + + identifierGroups.TryGetValue(lifecycle.UserKey, out var ids); + + var username = ids?.FirstOrDefault(x => + x.Type == UserIdentifierType.Username && + x.IsPrimary); + + var email = ids?.FirstOrDefault(x => + x.Type == UserIdentifierType.Email && + x.IsPrimary); + + summaries.Add(new UserSummary + { + UserKey = lifecycle.UserKey, + DisplayName = profile?.DisplayName, + UserName = username?.Value, + PrimaryEmail = email?.Value, + Status = lifecycle.Status, + CreatedAt = lifecycle.CreatedAt + }); + } + + // SEARCH + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToLowerInvariant(); + + summaries = summaries + .Where(x => + (x.DisplayName?.ToLowerInvariant().Contains(search) ?? false) || + (x.PrimaryEmail?.ToLowerInvariant().Contains(search) ?? false) || + (x.UserName?.ToLowerInvariant().Contains(search) ?? false) || + x.UserKey.Value.ToLowerInvariant().Contains(search)) + .ToList(); + } + + // SORT + summaries = query.SortBy switch + { + nameof(UserSummary.DisplayName) => + query.Descending + ? summaries.OrderByDescending(x => x.DisplayName).ToList() + : summaries.OrderBy(x => x.DisplayName).ToList(), + + nameof(UserSummary.CreatedAt) => + query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt).ToList() + : summaries.OrderBy(x => x.CreatedAt).ToList(), + + _ => summaries.OrderBy(x => x.CreatedAt).ToList() + }; + + var total = summaries.Count; + + // PAGINATION + var items = summaries + .Skip((query.PageNumber - 1) * query.PageSize) + .Take(query.PageSize) + .ToList(); + + return new PagedResult( + items, + total, + query.PageNumber, + query.PageSize, + query.SortBy, + query.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 2560e51b..407b03a3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -16,5 +16,6 @@ public interface IUserIdentifierStore : IVersionedStore Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default); + Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index c8151c27..dbedae45 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -7,4 +8,5 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore : IVersionedStore { Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default); + Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default); }