diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index ee6b5d9..1f15cee 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -12,15 +12,25 @@ + + + + - + + + + + + + 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 f0dc5ec..fac1f58 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 @@ -1,4 +1,4 @@ - + net10.0 @@ -14,13 +14,21 @@ + + + + + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index 7ae27ac..a2c2e59 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -78,8 +78,8 @@ private async Task ProgrammaticPkceLogin() var request = new PkceLoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, CodeVerifier = credentials?.CodeVerifier ?? string.Empty, ReturnUrl = _state?.ReturnUrl ?? string.Empty diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 67c4055..c6aef00 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -1,16 +1,23 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; -using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.Runtime; -using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +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.Extensions; -using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; using MudBlazor.Services; using MudExtensions.Services; @@ -46,7 +53,12 @@ //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) - .AddInMemoryCredentials() + .AddUltimateAuthUsersInMemory() + .AddUltimateAuthUsersReference() + .AddUltimateAuthCredentialsInMemory() + .AddUltimateAuthCredentialsReference() + .AddUltimateAuthAuthorizationInMemory() + .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); @@ -73,6 +85,19 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService>(); + + var seeder = scope.ServiceProvider.GetService(); + //if (seeder is not null) + // await seeder.SeedAsync(); + + +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { 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 69effd5..e54c2fa 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 @@ -13,13 +13,21 @@ + + + + + + + + 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 c36676a..a36836b 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 @@ -64,6 +64,12 @@ Not Authorized context is shown. + + + + This is Admin content. + + 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 806ffd9..2c46268 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 @@ -41,8 +41,8 @@ private async Task ProgrammaticLogin() var deviceId = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", Device = DeviceContext.FromDeviceId(deviceId), }; await UAuthClient.LoginAsync(request); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index e449e4e..b16df96 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -1,13 +1,20 @@ +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; +using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; -using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Security.Argon2; using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Extensions; using Microsoft.AspNetCore.Components; using MudBlazor.Services; using MudExtensions.Services; @@ -44,7 +51,12 @@ //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); }) - .AddInMemoryCredentials() + .AddUltimateAuthUsersInMemory() + .AddUltimateAuthUsersReference() + .AddUltimateAuthCredentialsInMemory() + .AddUltimateAuthCredentialsReference() + .AddUltimateAuthAuthorizationInMemory() + .AddUltimateAuthAuthorizationReference() .AddUltimateAuthInMemorySessions() .AddUltimateAuthInMemoryTokens() .AddUltimateAuthArgon2(); @@ -80,6 +92,17 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService>(); + + var seeder = scope.ServiceProvider.GetService(); + //if (seeder is not null) + // await seeder.SeedAsync(); +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj index d1f3837..36d7dae 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj @@ -17,8 +17,11 @@ + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor index e2f5e68..da54578 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -59,6 +59,10 @@ Not Authorized context is shown. + + + This is Admin content. + 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 f4a6fef..fc4d08d 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 @@ -46,8 +46,8 @@ private async Task ProgrammaticLogin() var device = await DeviceIdProvider.GetOrCreateAsync(); var request = new LoginRequest { - Identifier = "Admin", - Secret = "Password!", + Identifier = "admin", + Secret = "admin", Device = DeviceContext.FromDeviceId(device), }; await UAuthClient.LoginAsync(request); diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 3d97996..5ead5c5 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -30,6 +30,8 @@ + + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs new file mode 100644 index 0000000..1dc892e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthContextFactory +{ + AuthContext Create(DateTimeOffset? at = null); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs new file mode 100644 index 0000000..bf61d88 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAccessAuthority + { + AccessDecision Decide(AccessContext context, IEnumerable runtimePolicies); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs new file mode 100644 index 0000000..806d6c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAccessInvariant + { + AccessDecision Decide(AccessContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs new file mode 100644 index 0000000..487072f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAccessPolicy + { + bool AppliesTo(AccessContext context); + AccessDecision Decide(AccessContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs index 9da4a8f..9a29458 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -4,7 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { public interface IAuthAuthority { - AuthorizationResult Decide(AuthContext context); + AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null); } - } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs index 32ce7dc..dc0cc0a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions { public interface IAuthorityInvariant { - AuthorizationResult Decide(AuthContext context); + AccessDecisionResult Decide(AuthContext context); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs index 235ea3d..2b2021a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -5,6 +5,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions public interface IAuthorityPolicy { bool AppliesTo(AuthContext context); - AuthorizationResult Decide(AuthContext context); + AccessDecisionResult Decide(AuthContext context); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs index 53246c1..d6596c9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -8,6 +8,6 @@ public interface IUAuthPasswordHasher { string Hash(string password); - bool Verify(string password, string hash); + bool Verify(string hash, string secret); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs deleted file mode 100644 index 21ee5ad..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - public interface IUserAuthenticator - { - Task> AuthenticateAsync(string? tenantId, AuthenticationContext context, CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs new file mode 100644 index 0000000..3be15c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core; + +public interface IUserClaimsProvider +{ + Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs new file mode 100644 index 0000000..9dc9df9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface ISessionService + { + Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); + Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct = default); + Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs index c2c3423..9486398 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -5,11 +5,11 @@ /// Provides access to authentication flows, /// session lifecycle and user operations. /// - public interface IUAuthService - { - //IUAuthFlowService Flow { get; } - IUAuthSessionManager Sessions { get; } - //IUAuthTokenService Tokens { get; } - IUAuthUserService Users { get; } - } + //public interface IUAuthService + //{ + // //IUAuthFlowService Flow { get; } + // IUAuthSessionManager Sessions { get; } + // //IUAuthTokenService Tokens { get; } + // IUAuthUserService Users { get; } + //} } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs index af6e3ba..d546e55 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs @@ -1,15 +1,15 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Defines the minimal user authentication contract expected by UltimateAuth. - /// This service does not manage sessions, tokens, or transport concerns. - /// For user management, CodeBeam.UltimateAuth.Users package is recommended. - /// - public interface IUAuthUserService - { - Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); - Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); - } -} +//namespace CodeBeam.UltimateAuth.Core.Abstractions +//{ +// /// +// /// Defines the minimal user authentication contract expected by UltimateAuth. +// /// This service does not manage sessions, tokens, or transport concerns. +// /// For user management, CodeBeam.UltimateAuth.Users package is recommended. +// /// +// public interface IUAuthUserService +// { +// Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); +// Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index 96f4f54..b97c47e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -10,16 +10,16 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface IUAuthUserStore { - Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); - Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); + Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default); /// /// Retrieves a user by a login credential such as username or email. /// Returns null if no matching user exists. /// /// The user instance or null if not found. - Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); + Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken token = default); /// /// Returns the password hash for the specified user, if the user participates diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs new file mode 100644 index 0000000..74f8416 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class AccessContext + { + public string? TenantId { get; init; } + public UserKey? ActorUserKey { get; init; } + public string Action { get; init; } = default!; + public string? Resource { get; init; } + public string? ResourceId { get; init; } + public IReadOnlyDictionary? Attributes { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs new file mode 100644 index 0000000..2320615 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -0,0 +1,40 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record AccessDecision + { + public bool IsAllowed { get; } + public bool RequiresReauthentication { get; } + public string? DenyReason { get; } + + private AccessDecision( + bool isAllowed, + bool requiresReauthentication, + string? denyReason) + { + IsAllowed = isAllowed; + RequiresReauthentication = requiresReauthentication; + DenyReason = denyReason; + } + + public static AccessDecision Allow() + => new( + isAllowed: true, + requiresReauthentication: false, + denyReason: null); + + public static AccessDecision Deny(string reason) + => new( + isAllowed: false, + requiresReauthentication: false, + denyReason: reason); + + public static AccessDecision ReauthenticationRequired(string? reason = null) + => new( + isAllowed: false, + requiresReauthentication: true, + denyReason: reason); + + public bool IsDenied => + !IsAllowed && !RequiresReauthentication; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs similarity index 68% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs index 09af255..e157c94 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs @@ -1,23 +1,23 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { - public sealed class AuthorizationResult + public sealed class AccessDecisionResult { public AuthorizationDecision Decision { get; } public string? Reason { get; } - private AuthorizationResult(AuthorizationDecision decision, string? reason) + private AccessDecisionResult(AuthorizationDecision decision, string? reason) { Decision = decision; Reason = reason; } - public static AuthorizationResult Allow() + public static AccessDecisionResult Allow() => new(AuthorizationDecision.Allow, null); - public static AuthorizationResult Deny(string reason) + public static AccessDecisionResult Deny(string reason) => new(AuthorizationDecision.Deny, reason); - public static AuthorizationResult Challenge(string reason) + public static AccessDecisionResult Challenge(string reason) => new(AuthorizationDecision.Challenge, reason); // Developer happiness helpers diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs deleted file mode 100644 index 53027da..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthenticationContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contracts -{ - public sealed record AuthenticationContext - { - public string? TenantId { get; init; } - public string Identifier { get; init; } = default!; - public string Secret { get; init; } = default!; - public AuthOperation Operation { get; init; } // Login, Reauth, Validate - public DeviceContext? Device { get; init; } - public string CredentialType { get; init; } = "password"; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs new file mode 100644 index 0000000..e28fa7b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum DeleteMode + { + Soft, + Hard + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index f9be49f..8324739 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -18,7 +18,12 @@ public sealed record LoginResult public bool RequiresMfa => Continuation?.Type == LoginContinuationType.Mfa; public bool RequiresPkce => Continuation?.Type == LoginContinuationType.Pkce; - public static LoginResult Failed() => new() { Status = LoginStatus.Failed }; + public static LoginResult Failed(AuthFailureReason? reason = null) + => new() + { + Status = LoginStatus.Failed, + FailureReason = reason + }; public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) => new() diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs index 62b8fd1..ef8cdcc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs @@ -1,6 +1,16 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { // This is for AuthFlowContext, with minimal data and no db access + /// + /// Represents the minimal authentication state of the current request. + /// This type is request-scoped and contains no domain or persistence data. + /// + /// AuthUserSnapshot answers only the question: + /// "Is there an authenticated user associated with this execution context?" + /// + /// It must not be used for user discovery, lifecycle decisions, + /// or authorization policies. + /// public sealed class AuthUserSnapshot { public bool IsAuthenticated { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs index 0c87265..2063d0c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -5,7 +5,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts public sealed class UserContext { public TUserId? UserId { get; init; } - public IUser? User { get; init; } + public IAuthSubject? User { get; init; } public bool IsAuthenticated => UserId is not null; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs index ed0ca74..e18593a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -20,6 +20,11 @@ public enum AuthFlowType UserInfo, PermissionQuery, + UserManagement, + UserProfile, + CredentialManagement, + AuthorizationManagement, + ApiAccess } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs new file mode 100644 index 0000000..399cad5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/ICurrentUser.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core; + +public interface ICurrentUser +{ + bool IsAuthenticated { get; } + UserKey UserKey { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs new file mode 100644 index 0000000..6ceca57 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class ClaimsSnapshotBuilder + { + private readonly Dictionary> _claims = new(StringComparer.Ordinal); + + public ClaimsSnapshotBuilder Add(string type, string value) + { + if (!_claims.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + _claims[type] = set; + } + + set.Add(value); + return this; + } + + public ClaimsSnapshotBuilder AddMany(string type, IEnumerable values) + { + foreach (var v in values) + Add(type, v); + + return this; + } + + public ClaimsSnapshotBuilder AddRole(string role) => Add(ClaimTypes.Role, role); + + public ClaimsSnapshotBuilder AddPermission(string permission) => Add("uauth:permission", permission); + + public ClaimsSnapshot Build() + { + var frozen = _claims.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal); + return new ClaimsSnapshot(frozen); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 2190461..c6f5f7b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -5,24 +5,60 @@ namespace CodeBeam.UltimateAuth.Core.Domain { public sealed class ClaimsSnapshot { - public IReadOnlyDictionary Claims { get; } + private readonly IReadOnlyDictionary> _claims; + public IReadOnlyDictionary> Claims => _claims; [JsonConstructor] - public ClaimsSnapshot(IReadOnlyDictionary claims) + public ClaimsSnapshot(IReadOnlyDictionary> claims) { - Claims = new Dictionary(claims); + _claims = claims; } - public IReadOnlyDictionary AsDictionary() => Claims; + public static ClaimsSnapshot Empty { get; } = new(new Dictionary>()); - public bool TryGet(string type, out string value) => Claims.TryGetValue(type, out value); + public string? Get(string type) => _claims.TryGetValue(type, out var values) ? values.FirstOrDefault() : null; + public bool TryGet(string type, out string value) + { + value = null!; + + if (!Claims.TryGetValue(type, out var values)) + return false; + + var first = values.FirstOrDefault(); + if (first is null) + return false; + + value = first; + return true; + } + public IReadOnlyCollection GetAll(string type) => _claims.TryGetValue(type, out var values) ? values : Array.Empty(); + + public bool Has(string type) => _claims.ContainsKey(type); + public bool HasValue(string type, string value) => _claims.TryGetValue(type, out var values) && values.Contains(value); + + public IReadOnlyCollection Roles => GetAll(ClaimTypes.Role); + public IReadOnlyCollection Permissions => GetAll("uauth:permission"); + + public bool IsInRole(string role) => HasValue(ClaimTypes.Role, role); + public bool HasPermission(string permission) => HasValue("uauth:permission", permission); + + /// + /// Flattens claims by taking the first value of each claim. + /// Useful for logging, diagnostics, or legacy consumers. + /// + public IReadOnlyDictionary AsDictionary() + { + var dict = new Dictionary(StringComparer.Ordinal); - public string? Get(string type) - => Claims.TryGetValue(type, out var value) - ? value - : null; + foreach (var (type, values) in Claims) + { + var first = values.FirstOrDefault(); + if (first is not null) + dict[type] = first; + } - public static ClaimsSnapshot Empty { get; } = new ClaimsSnapshot(new Dictionary()); + return dict; + } public override bool Equals(object? obj) { @@ -32,12 +68,15 @@ public override bool Equals(object? obj) if (Claims.Count != other.Claims.Count) return false; - foreach (var kv in Claims) + foreach (var (type, values) in Claims) { - if (!other.Claims.TryGetValue(kv.Key, out var v)) + if (!other.Claims.TryGetValue(type, out var otherValues)) + return false; + + if (values.Count != otherValues.Count) return false; - if (!string.Equals(kv.Value, v, StringComparison.Ordinal)) + if (!values.All(v => otherValues.Contains(v))) return false; } @@ -49,22 +88,37 @@ public override int GetHashCode() unchecked { int hash = 17; - foreach (var kv in Claims.OrderBy(x => x.Key)) + + foreach (var (type, values) in Claims.OrderBy(x => x.Key)) { - hash = hash * 23 + kv.Key.GetHashCode(); - hash = hash * 23 + kv.Value.GetHashCode(); + hash = hash * 23 + type.GetHashCode(); + + foreach (var value in values.OrderBy(v => v)) + { + hash = hash * 23 + value.GetHashCode(); + } } + return hash; } } public static ClaimsSnapshot From(params (string Type, string Value)[] claims) { - var dict = new Dictionary(StringComparer.Ordinal); + var dict = new Dictionary>(StringComparer.Ordinal); + foreach (var (type, value) in claims) - dict[type] = value; + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } - return new ClaimsSnapshot(dict); + set.Add(value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); } public ClaimsSnapshot With(params (string Type, string Value)[] claims) @@ -72,14 +126,20 @@ public ClaimsSnapshot With(params (string Type, string Value)[] claims) if (claims.Length == 0) return this; - var dict = new Dictionary(Claims, StringComparer.Ordinal); + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); foreach (var (type, value) in claims) { - dict[type] = value; + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } + + set.Add(value); } - return new ClaimsSnapshot(dict); + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); } public ClaimsSnapshot Merge(ClaimsSnapshot other) @@ -90,44 +150,24 @@ public ClaimsSnapshot Merge(ClaimsSnapshot other) if (Claims.Count == 0) return other; - var dict = new Dictionary(Claims, StringComparer.Ordinal); + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); - foreach (var kv in other.Claims) + foreach (var (type, values) in other.Claims) { - dict[kv.Key] = kv.Value; - } - - return new ClaimsSnapshot(dict); - } - - public static ClaimsSnapshot FromClaimsPrincipal(ClaimsPrincipal principal) - { - if (principal is null) - return Empty; - - if (principal.Identity?.IsAuthenticated != true) - return Empty; - - var dict = new Dictionary(StringComparer.Ordinal); + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } - foreach (var claim in principal.Claims) - { - dict[claim.Type] = claim.Value; + foreach (var value in values) + set.Add(value); } - return new ClaimsSnapshot(dict); + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); } - public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = "UltimateAuth") - { - if (Claims.Count == 0) - return new ClaimsPrincipal(new ClaimsIdentity()); - - var claims = Claims.Select(kv => new Claim(kv.Key, kv.Value)); - var identity = new ClaimsIdentity(claims, authenticationType); - - return new ClaimsPrincipal(identity); - } + public static ClaimsSnapshotBuilder Create() => new ClaimsSnapshotBuilder(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs index 5222ca9..97eec36 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs @@ -5,7 +5,7 @@ /// Includes the unique user identifier and an optional set of claims that /// may be used during authentication or session creation. /// - public interface IUser + public interface IAuthSubject { /// /// Gets the unique identifier of the user. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs index f696d21..3e42de9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -1,6 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Domain +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain { - public readonly record struct UserKey + [JsonConverter(typeof(UserKeyJsonConverter))] + public readonly record struct UserKey : IParsable { public string Value { get; } @@ -31,6 +35,32 @@ public static UserKey FromString(string value) /// public static UserKey New() => FromGuid(Guid.NewGuid()); + public static bool TryParse(string? s, IFormatProvider? provider, out UserKey result) + { + if (string.IsNullOrWhiteSpace(s)) + { + result = default; + return false; + } + + if (Guid.TryParse(s, out var guid)) + { + result = FromGuid(guid); + return true; + } + + result = FromString(s); + return true; + } + + public static UserKey Parse(string s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + throw new FormatException($"Invalid UserKey value: '{s}'"); + + return result; + } + public override string ToString() => Value; public static implicit operator string(UserKey key) => key.Value; diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs deleted file mode 100644 index 2a8688d..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimSnapshotExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Security.Claims; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Extensions -{ - public static class ClaimsSnapshotExtensions - { - public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") - { - var claims = snapshot - .AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)); - - var identity = new ClaimsIdentity(claims, authenticationType); - return new ClaimsPrincipal(identity); - } - - public static IReadOnlyCollection ToClaims(this ClaimsSnapshot snapshot) - { - if (snapshot == null) - return Array.Empty(); - - return snapshot - .AsDictionary() - .Select(kv => new Claim(kv.Key, kv.Value)) - .ToArray(); - } - - public static ClaimsSnapshot ToSnapshot(this IEnumerable claims) - { - if (claims == null) - return ClaimsSnapshot.Empty; - - return new ClaimsSnapshot( - claims - .GroupBy(c => c.Type) - .ToDictionary( - g => g.Key, - g => g.Last().Value, - StringComparer.Ordinal - ) - ); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 0000000..fa7bc2f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,61 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Extensions +{ + public static class ClaimsSnapshotExtensions + { + /// + /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. + /// + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = "UltimateAuth") + { + if (snapshot == null) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(value => new Claim(kv.Key, value))); + + var identity = new ClaimsIdentity(claims, authenticationType); + return new ClaimsPrincipal(identity); + } + + /// + /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. + /// + public static ClaimsSnapshot ToClaimsSnapshot(this ClaimsPrincipal principal) + { + if (principal is null) + return ClaimsSnapshot.Empty; + + if (principal.Identity?.IsAuthenticated != true) + return ClaimsSnapshot.Empty; + + var dict = new Dictionary>(StringComparer.Ordinal); + + foreach (var claim in principal.Claims) + { + if (!dict.TryGetValue(claim.Type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[claim.Type] = set; + } + + set.Add(claim.Value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } + + public static IEnumerable ToClaims(this ClaimsSnapshot snapshot) + { + foreach (var (type, values) in snapshot.Claims) + { + foreach (var value in values) + { + yield return new Claim(type, value); + } + } + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs new file mode 100644 index 0000000..79885c2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthUserRecord.cs @@ -0,0 +1,50 @@ +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + /// + /// Represents the minimal, immutable user snapshot required by the UltimateAuth Core + /// during authentication discovery and subject binding. + /// + /// This type is NOT a domain user model. + /// It contains only normalized, opinionless fields that determine whether + /// a user can participate in authentication flows. + /// + /// AuthUserRecord is produced by the Users domain as a boundary projection + /// and is never mutated by the Core. + /// + public sealed record AuthUserRecord + { + /// + /// Application-level user identifier. + /// + public required TUserId Id { get; init; } + + /// + /// Primary login identifier (username, email, etc). + /// Used only for discovery and uniqueness checks. + /// + public required string Identifier { get; init; } + + /// + /// Indicates whether the user is considered active for authentication purposes. + /// Domain-specific statuses are normalized into this flag by the Users domain. + /// + public required bool IsActive { get; init; } + + /// + /// Indicates whether the user is deleted. + /// Deleted users are never eligible for authentication. + /// + public required bool IsDeleted { get; init; } + + /// + /// The timestamp when the user was originally created. + /// Provided for invariant validation and auditing purposes. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// The timestamp when the user was deleted, if applicable. + /// + public DateTimeOffset? DeletedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs index 830ebfe..1b82692 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs @@ -14,9 +14,8 @@ public DefaultAuthAuthority(IEnumerable invariants, IEnumer _policies = policies ?? Array.Empty(); } - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null) { - // 1. Invariants foreach (var invariant in _invariants) { var result = invariant.Decide(context); @@ -24,10 +23,11 @@ public AuthorizationResult Decide(AuthContext context) return result; } - // 2. Policies bool challenged = false; - foreach (var policy in _policies) + var effectivePolicies = _policies.Concat(policies ?? Enumerable.Empty()); + + foreach (var policy in effectivePolicies) { if (!policy.AppliesTo(context)) continue; @@ -42,8 +42,9 @@ public AuthorizationResult Decide(AuthContext context) } return challenged - ? AuthorizationResult.Challenge("Additional verification required.") - : AuthorizationResult.Allow(); + ? AccessDecisionResult.Challenge("Additional verification required.") + : AccessDecisionResult.Allow(); } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs index d8472f8..1d53f38 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceMismatchPolicy.cs @@ -8,7 +8,7 @@ public sealed class DeviceMismatchPolicy : IAuthorityPolicy public bool AppliesTo(AuthContext context) => context.Device is not null; - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { var device = context.Device; @@ -18,14 +18,14 @@ public AuthorizationResult Decide(AuthContext context) return context.Operation switch { AuthOperation.Access => - AuthorizationResult.Deny("Access from unknown device."), + AccessDecisionResult.Deny("Access from unknown device."), AuthOperation.Refresh => - AuthorizationResult.Challenge("Device verification required."), + AccessDecisionResult.Challenge("Device verification required."), - AuthOperation.Login => AuthorizationResult.Allow(), // login establishes device + AuthOperation.Login => AccessDecisionResult.Allow(), // login establishes device - _ => AuthorizationResult.Allow() + _ => AccessDecisionResult.Allow() }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs index a2fc709..5bcd732 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DevicePresenceInvariant.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class DevicePresenceInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) { if (context.Device is null) - return AuthorizationResult.Deny("Device information is required."); + return AccessDecisionResult.Deny("Device information is required."); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs index a3eedf6..cb9e14c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -6,22 +6,22 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class ExpiredSessionInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation == AuthOperation.Login) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); var session = context.Session; if (session is null) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); if (session.State == SessionState.Expired) { - return AuthorizationResult.Deny("Session has expired."); + return AccessDecisionResult.Deny("Session has expired."); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs index 1929bb5..7d8fe9a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -6,15 +6,15 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure { public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant { - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { if (context.Operation == AuthOperation.Login) - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); var session = context.Session; if (session is null) - return AuthorizationResult.Deny("Session is required for this operation."); + return AccessDecisionResult.Deny("Session is required for this operation."); if (session.State == SessionState.Invalid || session.State == SessionState.NotFound || @@ -22,10 +22,10 @@ public AuthorizationResult Decide(AuthContext context) session.State == SessionState.SecurityMismatch || session.State == SessionState.DeviceMismatch) { - return AuthorizationResult.Deny($"Session state is invalid: {session.State}"); + return AccessDecisionResult.Deny($"Session state is invalid: {session.State}"); } - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs index 5096041..459e4ca 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs @@ -7,33 +7,33 @@ public sealed class AuthModeOperationPolicy : IAuthorityPolicy { public bool AppliesTo(AuthContext context) => true; // Applies to all contexts - public AuthorizationResult Decide(AuthContext context) + public AccessDecisionResult Decide(AuthContext context) { return context.Mode switch { UAuthMode.PureOpaque => DecideForPureOpaque(context), UAuthMode.PureJwt => DecideForPureJwt(context), - UAuthMode.Hybrid => AuthorizationResult.Allow(), - UAuthMode.SemiHybrid => AuthorizationResult.Allow(), + UAuthMode.Hybrid => AccessDecisionResult.Allow(), + UAuthMode.SemiHybrid => AccessDecisionResult.Allow(), - _ => AuthorizationResult.Deny("Unsupported authentication mode.") + _ => AccessDecisionResult.Deny("Unsupported authentication mode.") }; } - private static AuthorizationResult DecideForPureOpaque(AuthContext context) + private static AccessDecisionResult DecideForPureOpaque(AuthContext context) { if (context.Operation == AuthOperation.Refresh) - return AuthorizationResult.Deny("Refresh operation is not supported in PureOpaque mode."); + return AccessDecisionResult.Deny("Refresh operation is not supported in PureOpaque mode."); - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } - private static AuthorizationResult DecideForPureJwt(AuthContext context) + private static AccessDecisionResult DecideForPureJwt(AuthContext context) { if (context.Operation == AuthOperation.Access) - return AuthorizationResult.Deny("Session-based access is not supported in PureJwt mode."); + return AccessDecisionResult.Deny("Session-based access is not supported in PureJwt mode."); - return AuthorizationResult.Allow(); + return AccessDecisionResult.Allow(); } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs new file mode 100644 index 0000000..3c61b2f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/IInMemoryUserIdProvider.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public interface IInMemoryUserIdProvider + { + TUserId GetAdminUserId(); + TUserId GetUserUserId(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index 26d0d1e..44465ac 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -33,11 +33,14 @@ public string ToString(TUserId id) { return id switch { - int v => v.ToString(CultureInfo.InvariantCulture), - long v => v.ToString(CultureInfo.InvariantCulture), + UserKey v => v.Value, Guid v => v.ToString("N"), string v => v, - _ => JsonSerializer.Serialize(id) + int v => v.ToString(CultureInfo.InvariantCulture), + long v => v.ToString(CultureInfo.InvariantCulture), + + _ => throw new InvalidOperationException($"Unsupported UserId type: {typeof(TUserId).FullName}. " + + "Provide a custom IUserIdConverter.") }; } @@ -62,11 +65,11 @@ public TUserId FromString(string value) { return typeof(TUserId) switch { - Type t when t == typeof(int) => (TUserId)(object)int.Parse(value, CultureInfo.InvariantCulture), - Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture), + Type t when t == typeof(UserKey) => (TUserId)(object)UserKey.FromString(value), Type t when t == typeof(Guid) => (TUserId)(object)Guid.Parse(value), Type t when t == typeof(string) => (TUserId)(object)value, - Type t when t == typeof(UserKey) => (TUserId)(object)UserKey.FromString(value), + Type t when t == typeof(int) => (TUserId)(object)int.Parse(value, CultureInfo.InvariantCulture), + Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture), _ => JsonSerializer.Deserialize(value) ?? throw new UAuthInternalException("Cannot deserialize TUserId") @@ -92,8 +95,7 @@ public bool TryFromString(string value, out TUserId? id) /// /// Binary data representing the user id. /// The reconstructed user id. - public TUserId FromBytes(byte[] binary) => - FromString(Encoding.UTF8.GetString(binary)); + public TUserId FromBytes(byte[] binary) => FromString(Encoding.UTF8.GetString(binary)); public bool TryFromBytes(byte[] binary, out TUserId? id) { diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs new file mode 100644 index 0000000..21f731e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class UserKeyJsonConverter : JsonConverter + { + public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("UserKey must be a string."); + + return UserKey.FromString(reader.GetString()!); + } + + public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs deleted file mode 100644 index a5ad9a1..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Infrastructure -{ - public sealed class UserRecord - { - public required TUserId Id { get; init; } - public required string Username { get; init; } - public required string PasswordHash { get; init; } - public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; - public bool RequiresMfa { get; init; } - public bool IsActive { get; init; } = true; - public DateTimeOffset CreatedAt { get; init; } - public bool IsDeleted { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index b2c02f8..6b3e618 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Auth diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs index bccddfa..cc23e9b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/UAuthAuthenticationHandler.cs @@ -1,8 +1,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -55,32 +56,47 @@ protected override async Task HandleAuthenticateAsync() if (!result.IsValid || result.UserKey is null) return AuthenticateResult.NoResult(); - var principal = CreatePrincipal(result); - var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); + var principal = result.Claims.ToClaimsPrincipal(UAuthCookieDefaults.AuthenticationScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthCookieDefaults.AuthenticationScheme)); - return AuthenticateResult.Success(ticket); + + //var principal = CreatePrincipal(result); + //var ticket = new AuthenticationTicket(principal,UAuthCookieDefaults.AuthenticationScheme); + + //return AuthenticateResult.Success(ticket); } private static ClaimsPrincipal CreatePrincipal(SessionValidationResult result) { - var claims = new List - { - new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), - new Claim("uauth:session_id", result.SessionId.ToString()) - }; - - if (!string.IsNullOrEmpty(result.TenantId)) - { - claims.Add(new Claim("uauth:tenant", result.TenantId)); - } - - // Session claims (snapshot) - foreach (var (key, value) in result.Claims.AsDictionary()) - { - claims.Add(new Claim(key, value)); - } - - var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); + //var claims = new List + //{ + // new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value), + // new Claim("uauth:session_id", result.SessionId.ToString()) + //}; + + //if (!string.IsNullOrEmpty(result.TenantId)) + //{ + // claims.Add(new Claim("uauth:tenant", result.TenantId)); + //} + + //// Session claims (snapshot) + //foreach (var (key, value) in result.Claims.AsDictionary()) + //{ + // if (key == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role") + // { + // foreach (var role in value.Split(',')) + // claims.Add(new Claim(ClaimTypes.Role, role)); + // } + // else + // { + // claims.Add(new Claim(key, value)); + // } + //} + + //var identity = new ClaimsIdentity(claims, UAuthCookieDefaults.AuthenticationScheme); + //return new ClaimsPrincipal(identity); + + var identity = new ClaimsIdentity(result.Claims.ToClaims(), UAuthCookieDefaults.AuthenticationScheme); return new ClaimsPrincipal(identity); } diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 442f314..f572a09 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -18,11 +18,13 @@ + + - - - - + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs new file mode 100644 index 0000000..53460a6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IAuthorizationEndpointHandler + { + Task CheckAsync(HttpContext ctx); + Task GetRolesAsync(UserKey userKey, HttpContext ctx); + Task AssignRoleAsync(UserKey userKey, HttpContext ctx); + Task RemoveRoleAsync(UserKey userKey, HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs new file mode 100644 index 0000000..2b1ac88 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface ICredentialEndpointHandler + { + Task SetInitialAsync(HttpContext ctx); + Task ResetPasswordAsync(HttpContext ctx); + Task RevokeAllAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs new file mode 100644 index 0000000..bc96fc7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserEndpointHandler + { + Task CreateAsync(HttpContext ctx); + Task ChangeStatusAsync(HttpContext ctx); + Task DeleteAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs new file mode 100644 index 0000000..7717e26 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserProfileAdminEndpointHandler + { + Task GetAsync(UserKey userKey, HttpContext ctx); + Task UpdateAsync(UserKey userKey, HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs new file mode 100644 index 0000000..afc9e36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints +{ + public interface IUserProfileEndpointHandler + { + Task GetAsync(HttpContext ctx); + Task UpdateAsync(HttpContext ctx); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 9131458..bb215f4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -53,13 +53,11 @@ public async Task LoginAsync(HttpContext ctx) if (string.IsNullOrWhiteSpace(identifier) || string.IsNullOrWhiteSpace(secret)) return RedirectFailure(ctx, AuthFailureReason.InvalidCredentials, authFlow.OriginalOptions); - var tenantCtx = ctx.GetTenantContext(); - var flowRequest = new LoginRequest { Identifier = identifier, Secret = secret, - TenantId = tenantCtx.TenantId, + TenantId = authFlow.TenantId, At = _clock.UtcNow, Device = authFlow.Device, RequestTokens = shouldIssueTokens diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs index 5bd10ac..f47f8a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultValidateEndpointHandler.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Endpoints diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 0d3ccc9..2815489 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -19,7 +19,7 @@ public class UAuthEndpointRegistrar : IAuthEndpointRegistrar { public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) { - // Base: /auth + // Default base: /auth string basePrefix = options.RoutePrefix.TrimStart('/'); bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; @@ -105,6 +105,72 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); } + + if (options.EnableUserLifecycleEndpoints) + { + var users = group.MapGroup("/users"); + + users.MapPost("", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + users.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + // Post is intended here + users.MapPost("/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } + + if (options.EnableUserProfileEndpoints) + { + var userProfile = group.MapGroup("/users"); + + userProfile.MapGet("/me", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) + => await h.GetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfile)); + + userProfile.MapPut("/me", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) + => await h.UpdateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + } + + if (options.EnableAdminChangeUserProfileEndpoints) + { + var admin = group.MapGroup("/admin/users"); + + admin.MapGet("/{userKey}/profile", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + admin.MapPut("/{userKey}/profile", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } + + if (options.EnableCredentialsEndpoints) + { + var credentials = group.MapGroup("/credentials"); + + credentials.MapPost("/initial", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.SetInitialAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/password/reset", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.ResetPasswordAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/revoke-all", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + } + + if (options.EnableAuthorizationEndpoints) + { + var authz = group.MapGroup("/authorization"); + + authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + authz.MapPost("/users/{userKey}/roles", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + authz.MapDelete("/users/{userKey}/roles", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs new file mode 100644 index 0000000..4b2c9b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextJsonExtensions.cs @@ -0,0 +1,26 @@ +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/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index daba2ba..9b771a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,23 +1,29 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Cookies; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure.Hub; using CodeBeam.UltimateAuth.Server.Infrastructure.Session; using CodeBeam.UltimateAuth.Server.Issuers; +using CodeBeam.UltimateAuth.Server.Login; +using CodeBeam.UltimateAuth.Server.Login.Orchestrators; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Server.Stores; -using CodeBeam.UltimateAuth.Server.Users; +using CodeBeam.UltimateAuth.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -31,12 +37,16 @@ public static class UAuthServerServiceCollectionExtensions public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services) { services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); return services.AddUltimateAuthServerInternal(); } public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) { services.AddUltimateAuth(configuration); + AddUsersInternal(services); + AddCredentialsInternal(services); services.Configure(configuration.GetSection("UltimateAuth:Server")); return services.AddUltimateAuthServerInternal(); @@ -45,6 +55,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action configure) { services.AddUltimateAuth(); + AddUsersInternal(services); + AddCredentialsInternal(services); services.Configure(configure); return services.AddUltimateAuthServerInternal(); @@ -123,16 +135,15 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddScoped(); // Public resolver - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); - services.AddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); - services.AddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); - services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); + services.TryAddScoped(typeof(IUAuthFlowService<>), typeof(UAuthFlowService<>)); + services.TryAddScoped(typeof(IRefreshFlowService), typeof(DefaultRefreshFlowService)); + services.TryAddScoped(typeof(IUAuthSessionManager), typeof(UAuthSessionManager)); - services.AddSingleton(); + services.TryAddSingleton(); // TODO: Allow custom cookie manager via options //services.AddSingleton(); @@ -154,20 +165,25 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); services.TryAddScoped(); - services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); + //services.TryAddScoped(typeof(IUserAuthenticator<>), typeof(DefaultUserAuthenticator<>)); services.TryAddScoped(typeof(ISessionOrchestrator), typeof(UAuthSessionOrchestrator)); + services.TryAddScoped(typeof(ILoginOrchestrator<>), typeof(DefaultLoginOrchestrator<>)); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(typeof(ISessionQueryService), typeof(UAuthSessionQueryService)); services.TryAddScoped(typeof(IRefreshTokenResolver), typeof(DefaultRefreshTokenResolver)); services.TryAddScoped(typeof(ISessionTouchService), typeof(DefaultSessionTouchService)); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -176,64 +192,97 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); - services.AddScoped(); + services.TryAddScoped(); - services.AddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); - services.AddScoped(); - services.AddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); + services.TryAddScoped(typeof(IRefreshTokenValidator), typeof(DefaultRefreshTokenValidator)); + services.TryAddScoped(); + services.TryAddScoped(typeof(IRefreshTokenRotationService), typeof(RefreshTokenRotationService)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); // ----------------------------- // ENDPOINTS // ----------------------------- services.AddHttpContextAccessor(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); // Endpoint handlers - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); - services.AddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.AddScoped>(); + services.TryAddScoped>(); services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); //services.TryAddScoped(); + services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.Converters.Add(new UserKeyJsonConverter()); + }); return services; } + // ========================= + // USERS (FRAMEWORK-REQUIRED) + // ========================= + internal static IServiceCollection AddUsersInternal(IServiceCollection services) + { + // Core user abstractions + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + + // Security state + //services.TryAddScoped(typeof(IUserSecurityEvents<>), typeof(DefaultUserSecurityEvents<>)); + + // TODO: Move this into AddAuthorizaionInternal method + services.TryAddScoped(typeof(IUserClaimsProvider), typeof(DefaultAuthorizationClaimsProvider)); + + return services; + } + + // ========================= + // CREDENTIALS (FRAMEWORK-REQUIRED) + // ========================= + internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) + { + services.TryAddScoped(); + return services; + } + + public static IServiceCollection AddUAuthServerInfrastructure(this IServiceCollection services) { // Flow orchestration @@ -243,9 +292,6 @@ public static IServiceCollection AddUAuthServerInfrastructure(this IServiceColle services.TryAddScoped(typeof(ISessionIssuer), typeof(UAuthSessionIssuer)); services.TryAddScoped(typeof(ITokenIssuer), typeof(UAuthTokenIssuer)); - // User service - services.TryAddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); - // Endpoints services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Auth/DefaultAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Auth/DefaultAuthContextFactory.cs new file mode 100644 index 0000000..00dde0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Auth/DefaultAuthContextFactory.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure.Auth +{ + internal sealed class DefaultAuthContextFactory : IAuthContextFactory + { + private readonly IAuthFlowContextAccessor _flow; + private readonly IClock _clock; + + public DefaultAuthContextFactory(IAuthFlowContextAccessor flow, IClock clock) + { + _flow = flow; + _clock = clock; + } + + public AuthContext Create(DateTimeOffset? at = null) + { + var flow = _flow.Current; + return flow.ToAuthContext(at ?? _clock.UtcNow); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultCredentialResponseWriter.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultCredentialResponseWriter.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultPrimaryCredentialResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/DefaultPrimaryCredentialResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs deleted file mode 100644 index a2e7b7a..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure -{ - public sealed class DefaultUserAuthenticator : IUserAuthenticator - { - private readonly IUAuthUserStore _userStore; - private readonly IUAuthPasswordHasher _passwordHasher; - - public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) - { - _userStore = userStore; - _passwordHasher = passwordHasher; - } - - public async Task> AuthenticateAsync(string? tenantId, AuthenticationContext context, CancellationToken ct = default) - { - if (context is null) - throw new ArgumentNullException(nameof(context)); - - if (!string.Equals(context.CredentialType, "password", StringComparison.Ordinal)) - return UserAuthenticationResult.Fail(); - - var user = await _userStore.FindByUsernameAsync(tenantId, context.Identifier, ct); - - if (user is null || !user.IsActive) - return UserAuthenticationResult.Fail(); - - if (!_passwordHasher.Verify(context.Secret, user.PasswordHash)) - return UserAuthenticationResult.Fail(); - - var claims = (user.Claims ?? ClaimsSnapshot.Empty) - .With( - (ClaimTypes.NameIdentifier, user.Id.ToString()!), - (ClaimTypes.Name, user.Username), - ("uauth:username", user.Username) - ); - - return UserAuthenticationResult.Success( - user.Id, - claims, - user.RequiresMfa - ); - - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs new file mode 100644 index 0000000..3f4f539 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/DefaultAccessAuthority.cs @@ -0,0 +1,60 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultAccessAuthority : IAccessAuthority + { + private readonly IEnumerable _invariants; + private readonly IEnumerable _globalPolicies; + + public DefaultAccessAuthority(IEnumerable invariants, IEnumerable globalPolicies) + { + _invariants = invariants ?? Array.Empty(); + _globalPolicies = globalPolicies ?? Array.Empty(); + } + + public AccessDecision Decide(AccessContext context, IEnumerable runtimePolicies) + { + foreach (var invariant in _invariants) + { + var result = invariant.Decide(context); + if (!result.IsAllowed) + return result; + } + + foreach (var policy in _globalPolicies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + // Allow here means "no objection", NOT permission + } + + bool requiresReauth = false; + + foreach (var policy in runtimePolicies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + if (result.RequiresReauthentication) + requiresReauth = true; + } + + return requiresReauth + ? AccessDecision.ReauthenticationRequired() + : AccessDecision.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs new file mode 100644 index 0000000..f72a257 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IAccessCommand + { + IEnumerable GetPolicies(AccessContext context); + Task ExecuteAsync(CancellationToken ct = default); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs new file mode 100644 index 0000000..337bf3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public interface IAccessOrchestrator + { + Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs new file mode 100644 index 0000000..cdffd57 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllSessionsCommand.cs @@ -0,0 +1,24 @@ +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.TenantId, UserKey, context.At, ct); + return Unit.Value; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs index 4f0d752..a4f272a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -13,17 +13,9 @@ public RevokeRootCommand(UserKey userKey) UserKey = userKey; } - public async Task ExecuteAsync( - AuthContext context, - ISessionIssuer issuer, - CancellationToken ct) + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) { - await issuer.RevokeRootAsync( - context.TenantId, - UserKey, - context.At, - ct); - + await issuer.RevokeRootAsync(context.TenantId, UserKey, context.At, ct); return Unit.Value; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs new file mode 100644 index 0000000..5b7920b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class UAuthAccessOrchestrator : IAccessOrchestrator + { + private readonly IAccessAuthority _authority; + private bool _executed; + + public UAuthAccessOrchestrator(IAccessAuthority authority) + { + _authority = authority; + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + if (_executed) + throw new InvalidOperationException("Access orchestrator can only be executed once."); + + _executed = true; + + var policies = command.GetPolicies(context) ?? Array.Empty(); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reuthenticate."); + + await command.ExecuteAsync(ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/IInnerSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs new file mode 100644 index 0000000..dcd8c17 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + internal sealed class HttpContextCurrentUser : ICurrentUser + { + private readonly IHttpContextAccessor _http; + + public HttpContextCurrentUser(IHttpContextAccessor http) + { + _http = http; + } + + public bool IsAuthenticated => Snapshot?.IsAuthenticated == true; + + public UserKey UserKey => Snapshot?.UserId ?? throw new InvalidOperationException("Current user is not authenticated."); + + private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UserMiddleware.UserContextKey] as AuthUserSnapshot; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/UserAccessorBridge.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs new file mode 100644 index 0000000..fd02425 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginAuthority.cs @@ -0,0 +1,42 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Default implementation of the login authority. + /// Applies basic security checks for login attempts. + /// + public sealed class DefaultLoginAuthority : ILoginAuthority + { + public LoginDecision Decide(LoginDecisionContext context) + { + // 1. Credentials must be valid + if (!context.CredentialsValid) + { + return LoginDecision.Deny("Invalid credentials."); + } + + // 2. User must exist + if (!context.UserExists || context.UserKey is null) + { + // Deliberately vague to prevent user enumeration + return LoginDecision.Deny("Invalid credentials."); + } + + // 3. User must be active and not locked + if (context.SecurityState is not null) + { + if (context.SecurityState.IsLocked) + { + return LoginDecision.Deny("User account is locked."); + } + + if (context.SecurityState.RequiresReauthentication) + { + return LoginDecision.Challenge("Reauthentication required."); + } + } + + // 4. All checks passed + return LoginDecision.Allow(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs new file mode 100644 index 0000000..f878641 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs @@ -0,0 +1,168 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users; + +using CodeBeam.UltimateAuth.Core; + +namespace CodeBeam.UltimateAuth.Server.Login.Orchestrators +{ + internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator + { + private readonly ICredentialStore _credentialStore; + private readonly ICredentialValidator _credentialValidator; + private readonly IUserStore _users; + private readonly IUserSecurityStateProvider _userSecurityStateProvider; + private readonly ILoginAuthority _authority; + private readonly ISessionOrchestrator _sessionOrchestrator; + private readonly ITokenIssuer _tokens; + private readonly IUserClaimsProvider _claimsProvider; + private readonly IUserIdConverterResolver _userIdConverterResolver; + + public DefaultLoginOrchestrator( + ICredentialStore credentialStore, + ICredentialValidator credentialValidator, + IUserStore users, + IUserSecurityStateProvider userSecurityStateProvider, + ILoginAuthority authority, + ISessionOrchestrator sessionOrchestrator, + ITokenIssuer tokens, + IUserClaimsProvider claimsProvider, + IUserIdConverterResolver userIdConverterResolver) + { + _credentialStore = credentialStore; + _credentialValidator = credentialValidator; + _users = users; + _userSecurityStateProvider = userSecurityStateProvider; + _authority = authority; + _sessionOrchestrator = sessionOrchestrator; + _tokens = tokens; + _claimsProvider = claimsProvider; + _userIdConverterResolver = userIdConverterResolver; + } + + public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + { + var now = request.At ?? DateTimeOffset.UtcNow; + + // 1. Validate credentials (Credentials domain) + var credential = await _credentialStore.FindByLoginAsync(request.TenantId, request.Identifier, ct); + bool credentialsValid = false; + TUserId? userId = default; + + if (credential is not null) + { + var credentialValidationResult = await _credentialValidator.ValidateAsync(credential, request.Secret, ct); + credentialsValid = credentialValidationResult.IsValid; + userId = credential.UserId; + } + + bool userExists = userId is not null; + + // 2. Resolve user security state (Users domain) + IUserSecurityState? securityState = null; + UserKey? userKey = null; + + if (userExists) + { + securityState = await _userSecurityStateProvider.GetAsync(request.TenantId, userId!, ct); + var converter = _userIdConverterResolver.GetConverter(); + userKey = UserKey.FromString(converter.ToString(userId!)); + } + + // 3. Authority decision (Login domain) + var decisionContext = new LoginDecisionContext + { + TenantId = request.TenantId, + Identifier = request.Identifier, + CredentialsValid = credentialsValid, + UserExists = userExists, + UserKey = userKey, + SecurityState = securityState, + IsChained = request.ChainId is not null + }; + + var decision = _authority.Decide(decisionContext); + + switch (decision.Kind) + { + case LoginDecisionKind.Deny: + return LoginResult.Failed(); + + case LoginDecisionKind.Challenge: + { + // Orchestrator decides HOW to continue + var continuation = new LoginContinuation + { + Type = LoginContinuationType.Mfa, + // TODO: Add here + //ContinuationToken = _continuationTokenService.Create( + // request.TenantId, + // userKey!, + // request.ChainId), + Hint = decision.Reason + }; + + return LoginResult.Continue(continuation); + } + + case LoginDecisionKind.Allow: + break; + } + + if (userKey is not UserKey validUserKey) + { + return LoginResult.Failed(); + } + + var claims = await _claimsProvider.GetClaimsAsync(request.TenantId, validUserKey, ct); + + // 4. Create authenticated session + var sessionContext = new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserKey = validUserKey, + Now = now, + Device = request.Device, + Claims = claims, + ChainId = request.ChainId, + Metadata = SessionMetadata.Empty + }; + + var authContext = flow.ToAuthContext(now); + var issuedSession = await _sessionOrchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + + // 6. Issue tokens if requested + AuthTokens? tokens = null; + + if (request.RequestTokens) + { + var tokenContext = new TokenIssuanceContext + { + TenantId = request.TenantId, + UserKey = validUserKey, + SessionId = issuedSession.Session.SessionId, + ChainId = request.ChainId, + Claims = claims.AsDictionary() + }; + + var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); + var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); + + tokens = new AuthTokens + { + AccessToken = access, + RefreshToken = refresh + }; + } + + return LoginResult.Success(issuedSession.Session.SessionId, tokens); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs new file mode 100644 index 0000000..d05fac6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/ILoginAuthority.cs @@ -0,0 +1,17 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Represents the authority responsible for making login decisions. + /// This authority determines whether a login attempt is allowed, + /// denied, or requires additional verification (e.g. MFA). + /// + public interface ILoginAuthority + { + /// + /// Evaluates a login attempt based on the provided decision context. + /// + /// The login decision context. + /// The login decision. + LoginDecision Decide(LoginDecisionContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs new file mode 100644 index 0000000..d22d605 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/ILoginOrchestrator.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Orchestrates the login flow. + /// Responsible for executing the login process by coordinating + /// credential validation, user resolution, authority decision, + /// and session creation. + /// + public interface ILoginOrchestrator + { + Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs new file mode 100644 index 0000000..625d311 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecision.cs @@ -0,0 +1,26 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Represents the outcome of a login decision. + /// + public sealed class LoginDecision + { + public LoginDecisionKind Kind { get; } + public string? Reason { get; } + + private LoginDecision(LoginDecisionKind kind, string? reason = null) + { + Kind = kind; + Reason = reason; + } + + public static LoginDecision Allow() + => new(LoginDecisionKind.Allow); + + public static LoginDecision Deny(string reason) + => new(LoginDecisionKind.Deny, reason); + + public static LoginDecision Challenge(string reason) + => new(LoginDecisionKind.Challenge, reason); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs new file mode 100644 index 0000000..695d19d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionContext.cs @@ -0,0 +1,50 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Server.Login +{ + /// + /// Represents all information required by the login authority + /// to make a login decision. + /// + public sealed class LoginDecisionContext + { + /// + /// Gets the tenant identifier. + /// + public string? TenantId { get; init; } + + /// + /// Gets the login identifier (e.g. username or email). + /// + public required string Identifier { get; init; } + + /// + /// Indicates whether the provided credentials were successfully validated. + /// + public bool CredentialsValid { get; init; } + + /// + /// Gets the resolved user identifier if available. + /// + public UserKey? UserKey { get; init; } + + /// + /// Gets the user security state if the user could be resolved. + /// + public IUserSecurityState? SecurityState { get; init; } + + /// + /// Indicates whether the user exists. + /// This allows the authority to distinguish between + /// invalid credentials and non-existent users. + /// + public bool UserExists { get; init; } + + /// + /// Indicates whether this login attempt is part of a chained flow + /// (e.g. reauthentication, MFA completion). + /// + public bool IsChained { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs new file mode 100644 index 0000000..c1086d0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Login/LoginDecisionKind.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Server.Login +{ + public enum LoginDecisionKind + { + Allow = 1, + Deny = 2, + Challenge = 3 + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 1388620..6e3da9d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -108,6 +108,14 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public bool? EnableSessionEndpoints { get; set; } = true; public bool? EnableUserInfoEndpoints { get; set; } = true; + public bool EnableUserLifecycleEndpoints { get; set; } = true; + public bool EnableUserProfileEndpoints { get; set; } = true; + public bool EnableAdminChangeUserProfileEndpoints { get; set; } = false; + public bool EnableCredentialsEndpoints { get; set; } = true; + public bool EnableAuthorizationEndpoints { get; set; } = true; + + public UserIdentifierOptions UserIdentifiers { get; set; } = new(); + /// /// If true, server will add anti-forgery headers /// and require proper request metadata. @@ -137,7 +145,7 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public Action? ConfigureServices { get; set; } - internal Dictionary> ModeConfigurations { get; } = new(); + internal Dictionary> ModeConfigurations { get; set; } = new(); internal UAuthServerOptions Clone() @@ -159,16 +167,23 @@ internal UAuthServerOptions Clone() AuthResponse = AuthResponse.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), + UserIdentifiers = UserIdentifiers.Clone(), EnableLoginEndpoints = EnableLoginEndpoints, EnablePkceEndpoints = EnablePkceEndpoints, EnableTokenEndpoints = EnableTokenEndpoints, EnableSessionEndpoints = EnableSessionEndpoints, EnableUserInfoEndpoints = EnableUserInfoEndpoints, + EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, + EnableUserProfileEndpoints = EnableUserProfileEndpoints, + EnableAdminChangeUserProfileEndpoints = EnableAdminChangeUserProfileEndpoints, + EnableCredentialsEndpoints = EnableCredentialsEndpoints, + EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, EnableAntiCsrfProtection = EnableAntiCsrfProtection, EnableLoginRateLimiting = EnableLoginRateLimiting, + ModeConfigurations = ModeConfigurations, OnConfigureEndpoints = OnConfigureEndpoints, ConfigureServices = ConfigureServices, CustomCookieManagerType = CustomCookieManagerType diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs new file mode 100644 index 0000000..0f63cc9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UserIdentifierOptions.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Server.Options +{ + public sealed class UserIdentifierOptions + { + public bool AllowUsernameChange { get; set; } = true; + public bool AllowMultipleUsernames { get; set; } = false; + public bool AllowMultipleEmail { get; set; } = true; + public bool AllowMultiplePhone { get; set; } = true; + + public bool RequireEmailVerification { get; set; } = false; + public bool RequirePhoneVerification { get; set; } = false; + + public bool AllowAdminOverride { get; set; } = true; + public bool AllowUserOverride { get; set; } = true; + + internal UserIdentifierOptions Clone() => new() + { + AllowUsernameChange = AllowUsernameChange, + AllowMultipleUsernames = AllowMultipleUsernames, + AllowMultipleEmail = AllowMultipleEmail, + AllowMultiplePhone = AllowMultiplePhone, + RequireEmailVerification = RequireEmailVerification, + RequirePhoneVerification = RequirePhoneVerification, + AllowAdminOverride = AllowAdminOverride, + AllowUserOverride = AllowUserOverride + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs new file mode 100644 index 0000000..7e444b2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/DefaultSessionService.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class DefaultSessionService : ISessionService + { + private readonly ISessionOrchestrator _orchestrator; + private readonly IClock _clock; + + public DefaultSessionService(ISessionOrchestrator orchestrator, IClock clock) + { + _orchestrator = orchestrator; + _clock = clock; + } + + public Task RevokeAllAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeAllUserSessionsCommand(userKey), ct); + } + + public Task RevokeAllExceptChainAsync(AuthContext authContext, UserKey userKey, SessionChainId exceptChainId, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeAllChainsCommand(userKey, exceptChainId), ct); + } + + public Task RevokeRootAsync(AuthContext authContext, UserKey userKey, CancellationToken ct) + { + return _orchestrator.ExecuteAsync(authContext, new RevokeRootCommand(userKey), ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs index 64a9773..fa67421 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/ISessionQueryService.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Services { public interface ISessionQueryService { diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 833c7b1..6864311 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -6,13 +6,15 @@ using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Login; +using System; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthFlowService : IUAuthFlowService { private readonly IAuthFlowContextAccessor _authFlow; - private readonly IUAuthUserService _users; + private readonly ILoginOrchestrator _loginOrchestrator; private readonly ISessionOrchestrator _orchestrator; private readonly ISessionQueryService _queries; private readonly ITokenIssuer _tokens; @@ -21,7 +23,7 @@ internal sealed class UAuthFlowService : IUAuthFlowService public UAuthFlowService( IAuthFlowContextAccessor authFlow, - IUAuthUserService users, + ILoginOrchestrator loginOrchestrator, ISessionOrchestrator orchestrator, ISessionQueryService queries, ITokenIssuer tokens, @@ -29,7 +31,7 @@ public UAuthFlowService( IRefreshTokenValidator tokenValidator) { _authFlow = authFlow; - _users = users; + _loginOrchestrator = loginOrchestrator; _orchestrator = orchestrator; _queries = queries; _tokens = tokens; @@ -52,112 +54,17 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel throw new NotImplementedException(); } - public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTimeOffset.UtcNow; - - var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - - if (!auth.Succeeded) - return LoginResult.Failed(); - - var converter = _userIdConverterResolver.GetConverter(); - var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); - var sessionContext = new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserKey = userKey, - Now = now, - Device = request.Device, - Claims = auth.Claims, - ChainId = request.ChainId, - Metadata = SessionMetadata.Empty // TODO: Check all SessionMetadata.Empty statements - }; - - var authContext = flow.ToAuthContext(now); - var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); - - bool shouldIssueTokens = request.RequestTokens; - - AuthTokens? tokens = null; - - if (shouldIssueTokens) - { - var tokenContext = new TokenIssuanceContext - { - TenantId = request.TenantId, - UserKey = userKey, - SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, - Claims = auth.Claims.AsDictionary() - }; - - var access = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); - var refresh = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); - - tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh }; - } - - return LoginResult.Success(issuedSession.Session.SessionId, tokens); + return _loginOrchestrator.LoginAsync(flow, request, ct); } - public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + public Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTimeOffset.UtcNow; - - var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); - - if (!auth.Succeeded) - return LoginResult.Failed(); - - var converter = _userIdConverterResolver.GetConverter(); - var userKey = UserKey.FromString(converter.ToString(auth.UserId!)); - var sessionContext = new AuthenticatedSessionContext - { - TenantId = request.TenantId, - UserKey = userKey, - Now = now, - Device = request.Device, - Claims = auth.Claims, - ChainId = request.ChainId, - Metadata = SessionMetadata.Empty - }; - - var authContext = flow.ToAuthContext(now); - var issuedSession = await _orchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); - - bool shouldIssueTokens = request.RequestTokens; - - AuthTokens? tokens = null; - - if (shouldIssueTokens) - { - var tokenContext = new TokenIssuanceContext - { - TenantId = request.TenantId, - UserKey = userKey, - SessionId = issuedSession.Session.SessionId, - ChainId = request.ChainId, - Claims = auth.Claims.AsDictionary() - }; - - - var effectiveFlow = execution.EffectiveClientProfile is null - ? flow - : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); - - var access = await _tokens.IssueAccessTokenAsync(effectiveFlow, tokenContext, ct); - - var refresh = await _tokens.IssueRefreshTokenAsync(effectiveFlow, tokenContext, RefreshTokenPersistence.Persist, ct); - - tokens = new AuthTokens - { - AccessToken = access, - RefreshToken = refresh - }; - } - - return LoginResult.Success(issuedSession.Session.SessionId, tokens); + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : flow.WithClientProfile((UAuthClientProfile)execution.EffectiveClientProfile); + return _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); } public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 9bd0336..a6736a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -1,19 +1,24 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; -namespace CodeBeam.UltimateAuth.Server.Infrastructure +namespace CodeBeam.UltimateAuth.Server.Services { public sealed class UAuthSessionQueryService : ISessionQueryService { private readonly ISessionStoreKernelFactory _storeFactory; + private readonly IUserClaimsProvider _claimsProvider; private readonly UAuthServerOptions _options; - public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IOptions options) + public UAuthSessionQueryService(ISessionStoreKernelFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) { _storeFactory = storeFactory; + _claimsProvider = claimsProvider; _options = options.Value; } @@ -45,7 +50,8 @@ public async Task ValidateSessionAsync(SessionValidatio //if (!session.Device.Matches(context.Device) && _options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) // return SessionValidationResult.Invalid(SessionState.DeviceMismatch); - return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, session.Claims, boundDeviceId: session.Device.DeviceId); + var claims = await _claimsProvider.GetClaimsAsync(context.TenantId, session.UserKey, ct); + return SessionValidationResult.Active(context.TenantId, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, boundDeviceId: session.Device.DeviceId); } public Task GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs deleted file mode 100644 index f76f804..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Users -{ - internal sealed class UAuthUserService : IUAuthUserService - { - private readonly IUserAuthenticator _authenticator; - - public UAuthUserService(IUserAuthenticator authenticator) - { - _authenticator = authenticator; - } - - public async Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken ct = default) - { - var context = new AuthenticationContext - { - Identifier = identifier, - Secret = secret, - CredentialType = "password" - }; - - return await _authenticator.AuthenticateAsync(tenantId, context, ct); - } - - // This method must not issue sessions or tokens - public async Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken ct = default) - { - var context = new AuthenticationContext - { - Identifier = request.Identifier, - Secret = request.Password, - CredentialType = "password" - }; - - var result = await _authenticator.AuthenticateAsync(request.TenantId,context, ct); - return result.Succeeded; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs deleted file mode 100644 index b03c400..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Users -{ - /// - /// Administrative user management operations. - /// - public interface IUAuthUserManagementService - { - Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); - - Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); - - Task> GetByIdAsync( - TUserId userId, - CancellationToken ct = default); - - Task>> GetAllAsync( - CancellationToken ct = default); - - Task DisableAsync( - TUserId userId, - CancellationToken ct = default); - - Task EnableAsync( - TUserId userId, - CancellationToken ct = default); - - Task ResetPasswordAsync( - TUserId userId, - ResetPasswordRequest request, - CancellationToken ct = default); - - // TODO: Change password, Update user info, etc. - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs deleted file mode 100644 index 68dedea..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - /// - /// User self-service operations (profile, password, MFA). - /// - public interface IUAuthUserProfileService - { - Task> GetCurrentAsync( - CancellationToken ct = default); - - Task UpdateProfileAsync( - UpdateProfileRequest request, - CancellationToken ct = default); - - Task ChangePasswordAsync( - ChangePasswordRequest request, - CancellationToken ct = default); - - Task ConfigureMfaAsync( - ConfigureMfaRequest request, - CancellationToken ct = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs deleted file mode 100644 index 87cec2b..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class AdminUserFilter - { - public bool? IsActive { get; init; } - public bool? IsEmailConfirmed { get; init; } - - public string? Search { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs deleted file mode 100644 index b53dfb6..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ChangePasswordRequest - { - public required string CurrentPassword { get; init; } - public required string NewPassword { get; init; } - - /// - /// If true, other sessions will be revoked. - /// - public bool RevokeOtherSessions { get; init; } = true; - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs deleted file mode 100644 index ff2b1d4..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ConfigureMfaRequest - { - public bool Enable { get; init; } - - /// - /// Optional verification code when enabling MFA. - /// - public string? VerificationCode { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs deleted file mode 100644 index 672d176..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class ResetPasswordRequest - { - public required string NewPassword { get; init; } - - /// - /// If true, all active sessions will be revoked. - /// - public bool RevokeSessions { get; init; } = true; - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs deleted file mode 100644 index f7688c9..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UpdateProfileRequest - { - public string? Username { get; init; } - public string? Email { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs deleted file mode 100644 index 5a8de73..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UserDto - { - public required TUserId UserId { get; init; } - - public string? Username { get; init; } - public string? Email { get; init; } - - public bool IsActive { get; init; } - public bool IsEmailConfirmed { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? LastLoginAt { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs deleted file mode 100644 index 141d34a..0000000 --- a/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CodeBeam.UltimateAuth.Server.Users -{ - public sealed class UserProfileDto - { - public required TUserId UserId { get; init; } - - public string? Username { get; init; } - public string? Email { get; init; } - - public bool IsEmailConfirmed { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj new file mode 100644 index 0000000..e03d745 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs new file mode 100644 index 0000000..10baf33 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/PermissionDto.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record PermissionDto +{ + public required string Value { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs new file mode 100644 index 0000000..cc50b19 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleDto.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RoleDto +{ + public required string Name { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs new file mode 100644 index 0000000..a4bf928 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed class AssignRoleRequest + { + public required string Role { get; init; } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs new file mode 100644 index 0000000..078a504 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed class AuthorizationCheckRequest + { + public required string Action { get; init; } + public string? Resource { get; init; } + public string? ResourceId { get; init; } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs new file mode 100644 index 0000000..4e3f0f3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AuthorizationRequest +{ + /// + /// Logical operation being requested (e.g. "orders.read"). + /// + public required string Operation { get; init; } + + /// + /// Optional resource identifier (row, entity, aggregate, etc). + /// + public string? Resource { get; init; } + + /// + /// Optional resource identifier. + /// + public string? ResourceId { get; init; } + + /// + /// Optional contextual attributes for fine-grained access decisions. + /// + public IReadOnlyDictionary? Attributes { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs new file mode 100644 index 0000000..1c3d78d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AuthorizationResult +{ + public required bool IsAllowed { get; init; } + + /// + /// Indicates whether re-authentication is required. + /// + public bool RequiresReauthentication { get; init; } + + /// + /// Optional reason code for denial. + /// + public string? DenyReason { get; init; } + + public static AuthorizationResult Allow() + => new() { IsAllowed = true }; + + public static AuthorizationResult Deny(string? reason = null) + => new() { IsAllowed = false, DenyReason = reason }; + + public static AuthorizationResult ReauthRequired() + => new() { IsAllowed = false, RequiresReauthentication = true }; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs new file mode 100644 index 0000000..17afba3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts +{ + public sealed record UserRolesResponse + { + public required UserKey UserKey { get; init; } + public required IReadOnlyCollection Roles { get; init; } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj new file mode 100644 index 0000000..c80fec6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs new file mode 100644 index 0000000..3cbbf12 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions +{ + public static class AuthorizationInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs new file mode 100644 index 0000000..e7d0561 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/IAuthorizationSeeder.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + public interface IAuthorizationSeeder + { + Task SeedAsync(CancellationToken ct = default); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs new file mode 100644 index 0000000..82af6b3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + internal sealed class InMemoryAuthorizationSeeder : IAuthorizationSeeder + { + private readonly IUserRoleStore _roles; + private readonly IInMemoryUserIdProvider _ids; + + public InMemoryAuthorizationSeeder(IUserRoleStore roles, IInMemoryUserIdProvider ids) + { + _roles = roles; + _ids = ids; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + var key = _ids.GetAdminUserId(); + await _roles.AssignAsync(null, key, "Admin", ct); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs new file mode 100644 index 0000000..e10afdf --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -0,0 +1,50 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory +{ + internal sealed class InMemoryUserRoleStore : IUserRoleStore + { + private readonly ConcurrentDictionary> _roles = new(); + + public InMemoryUserRoleStore(IInMemoryUserIdProvider ids) + { + var key = ids.GetAdminUserId(); + _roles[key] = new HashSet + { + "Admin" + }; + } + + public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roles.TryGetValue(userKey, out var set)) + return Task.FromResult>(set.ToArray()); + + return Task.FromResult>(Array.Empty()); + } + + public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = _roles.GetOrAdd(userKey, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + set.Add(role); + return Task.CompletedTask; + } + + public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roles.TryGetValue(userKey, out var set)) + set.Remove(role); + + return Task.CompletedTask; + } + } + +} 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 new file mode 100644 index 0000000..f42a8e7 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs new file mode 100644 index 0000000..2550b22 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Domain/Role.cs @@ -0,0 +1,7 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +public sealed class Role +{ + public required string Name { get; init; } + public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs new file mode 100644 index 0000000..09df3ba --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs @@ -0,0 +1,90 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultAuthorizationEndpointHandler : IAuthorizationEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthorizationService _authorization; + private readonly IUserRoleService _roles; + + public DefaultAuthorizationEndpointHandler(IAuthFlowContextAccessor authFlow, IAuthorizationService authorization, IUserRoleService roles) + { + _authFlow = authFlow; + _authorization = authorization; + _roles = roles; + } + + public async Task CheckAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var result = await _authorization.AuthorizeAsync(flow.TenantId, + new AccessContext + { + ActorUserKey = flow.UserKey!.Value, + Action = req.Action, + Resource = req.Resource, + ResourceId = req.ResourceId + }, + ctx.RequestAborted); + + return result.IsAllowed + ? Results.Ok(result) + : Results.Forbid(); + } + + public async Task GetRolesAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var roles = await _roles.GetRolesAsync(flow.TenantId, userKey, ctx.RequestAborted); + + return Results.Ok(new UserRolesResponse + { + UserKey = userKey, + Roles = roles + }); + } + + public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + await _roles.AssignAsync(flow.TenantId, userKey, req.Role, ctx.RequestAborted); + + return Results.Ok(); + } + + public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + await _roles.RemoveAsync(flow.TenantId, userKey, req.Role, ctx.RequestAborted); + + return Results.Ok(); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs new file mode 100644 index 0000000..8e49f8c --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/AuthorizationReferenceExtensions.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Endpoints; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.Reference.Extensions +{ + public static class AuthorizationReferenceExtensions + { + public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs new file mode 100644 index 0000000..2d2f3bd --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultRolePermissionResolver.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultRolePermissionResolver : 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(string? tenantId, IEnumerable roles, CancellationToken ct = default) + { + var result = new List(); + + foreach (var role in roles) + { + if (_map.TryGetValue(role, out var perms)) + result.AddRange(perms); + } + + return Task.FromResult>(result); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs new file mode 100644 index 0000000..a7aae0f --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserPermissionStore.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public sealed class DefaultUserPermissionStore : IUserPermissionStore + { + private readonly IUserRoleStore _roles; + private readonly IRolePermissionResolver _resolver; + + public DefaultUserPermissionStore(IUserRoleStore roles, IRolePermissionResolver resolver) + { + _roles = roles; + _resolver = resolver; + } + + public async Task> GetPermissionsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); + return await _resolver.ResolveAsync(tenantId, roles, ct); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserRoleService.cs new file mode 100644 index 0000000..3722ae8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/DefaultUserRoleService.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class DefaultUserRoleService : IUserRoleService + { + private readonly IUserRoleStore _store; + + public DefaultUserRoleService(IUserRoleStore store) + { + _store = store; + } + + public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("Role cannot be empty.", nameof(role)); + + return _store.AssignAsync(tenantId, userKey, role, ct); + } + + public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(role)) + throw new ArgumentException("Role cannot be empty.", nameof(role)); + + return _store.RemoveAsync(tenantId, userKey, role, ct); + } + + public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + return _store.GetRolesAsync(tenantId, userKey, ct); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs new file mode 100644 index 0000000..8b0ab3b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/DefaultAuthorizationService.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + internal sealed class DefaultAuthorizationService : IAuthorizationService + { + private readonly IUserPermissionStore _permissionStore; + private readonly IAccessAuthority _accessAuthority; + + public DefaultAuthorizationService(IUserPermissionStore permissionStore, IAccessAuthority accessAuthority) + { + _permissionStore = permissionStore; + _accessAuthority = accessAuthority; + } + + public async Task AuthorizeAsync(string? tenantId, AccessContext context, CancellationToken ct = default) + { + if (context.ActorUserKey is null) + return AuthorizationResult.Deny("unauthenticated"); + + var permissions = await _permissionStore.GetPermissionsAsync(tenantId, context.ActorUserKey.Value, ct); + + var policy = new PermissionAccessPolicy(permissions,context.Action); + + var decision = _accessAuthority.Decide(context, new[] { policy }); + + return decision.RequiresReauthentication + ? AuthorizationResult.ReauthRequired() + : decision.IsAllowed + ? AuthorizationResult.Allow() + : AuthorizationResult.Deny(decision.DenyReason); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs new file mode 100644 index 0000000..d0f41e5 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Reference +{ + public interface IAuthorizationService + { + Task AuthorizeAsync(string? tenantId, AccessContext context, CancellationToken ct = default); + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs new file mode 100644 index 0000000..7042d45 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IRolePermissionResolver + { + Task> ResolveAsync(string? tenantId, IEnumerable roles, CancellationToken ct = default); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs new file mode 100644 index 0000000..f093d40 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserPermissionStore +{ + Task> GetPermissionsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs new file mode 100644 index 0000000..c17a169 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IUserRoleService + { + Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + + Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + + Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs new file mode 100644 index 0000000..e2aae18 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public interface IUserRoleStore + { + Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default); + Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj new file mode 100644 index 0000000..e03d745 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs new file mode 100644 index 0000000..e5dff35 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/DefaultAuthorizationClaimsProvider.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Authorization +{ + public sealed class DefaultAuthorizationClaimsProvider : IUserClaimsProvider + { + private readonly IUserRoleStore _roles; + private readonly IUserPermissionStore _permissions; + + public DefaultAuthorizationClaimsProvider(IUserRoleStore roles, IUserPermissionStore permissions) + { + _roles = roles; + _permissions = permissions; + } + + public async Task GetClaimsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var roles = await _roles.GetRolesAsync(tenantId, userKey, ct); + var perms = await _permissions.GetPermissionsAsync(tenantId, userKey, ct); + + var builder = ClaimsSnapshot.Create(); + + foreach (var role in roles) + builder.Add(ClaimTypes.Role, role); + + foreach (var perm in perms) + builder.Add("uauth:permission", perm.Value); + + return builder.Build(); + } + } + +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs new file mode 100644 index 0000000..69dc0f0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Permission.cs @@ -0,0 +1,6 @@ +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/PermissionAccessPolicy.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs new file mode 100644 index 0000000..2a6848a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/PermissionAccessPolicy.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Authorization.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +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 bool AppliesTo(AccessContext context) => context.ActorUserKey is not null; + + public AccessDecision Decide(AccessContext context) + { + if (context.ActorUserKey is null) + return AccessDecision.Deny("unauthenticated"); + + return _permissions.Contains(_operation) + ? AccessDecision.Allow() + : AccessDecision.Deny("missing_permission"); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj new file mode 100644 index 0000000..e2fbe39 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj @@ -0,0 +1,16 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDescriptorDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDescriptorDto.cs new file mode 100644 index 0000000..f2afd18 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialDescriptorDto.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialDescriptorDto +{ + public CredentialType Type { get; init; } + + public string Identifier { get; init; } = default!; + + public bool IsActive { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + public DateTimeOffset? LastUsedAt { get; init; } + + public IReadOnlyDictionary? Attributes { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialStatus.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialStatus.cs new file mode 100644 index 0000000..3f8f698 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialStatus.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public enum CredentialStatus +{ + Active = 0, + + Disabled = 10, + Expired = 20, + Revoked = 30 +} + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs new file mode 100644 index 0000000..051024a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialType.cs @@ -0,0 +1,28 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public enum CredentialType +{ + Password = 0, + /// + /// Username / email / phone used for login. + /// + IdentifierUsername = 10, + IdentifierEmail = 11, + IdentifierPhone = 12, + + // Possession / OTP based + OneTimeCode = 30, + EmailOtp = 31, + SmsOtp = 32, + Totp = 50, + + // Modern + Passkey = 60, + + // Machine / system + Certificate = 70, + ApiKey = 80, + + // External / Federated // TODO: Add Microsoft, Google, GitHub etc. + External = 100 +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs new file mode 100644 index 0000000..85c5770 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialRequest +{ + public CredentialType Type { get; init; } + + public string CurrentSecret { get; init; } = default!; + public string NewSecret { get; init; } = default!; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs new file mode 100644 index 0000000..1e14453 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record ResetPasswordRequest + { + public UserKey UserKey { get; init; } = default!; + public required string NewPassword { get; init; } + + /// + /// Optional reset token or verification code. + /// + public string? Token { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs new file mode 100644 index 0000000..dda45bf --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeAllCredentialsRequest.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed class RevokeAllCredentialsRequest + { + public required UserKey UserKey { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs new file mode 100644 index 0000000..2ccd937 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/SetInitialCredentialRequest.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record SetInitialCredentialRequest +{ + /// + /// Credential type to initialize (Password, Passkey, External, etc.). + /// + public required CredentialType Type { get; init; } + + /// + /// Plain secret (password, passkey public data, external token reference). + /// Will be hashed / processed by the credential service. + /// + public required string Secret { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs new file mode 100644 index 0000000..bd53056 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ValidateCredentialsRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ValidateCredentialsRequest +{ + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + + public CredentialType? CredentialType { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs new file mode 100644 index 0000000..68d99fd --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialResult +{ + public CredentialType Type { get; init; } + + public required bool Succeeded { get; init; } + + /// + /// Indicates whether existing sessions / tokens were invalidated. + /// + public bool SecurityInvalidated { get; init; } + + public string? FailureReason { get; init; } + + public static ChangeCredentialResult Success(CredentialType type) + => new() + { + Succeeded = true, + Type = type, + SecurityInvalidated = false + }; + + public static ChangeCredentialResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs new file mode 100644 index 0000000..9ccd3c7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialChangeResult( + bool Succeeded, + bool SecurityInvalidated, + string? FailureReason = null); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs new file mode 100644 index 0000000..4952fda --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -0,0 +1,41 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialProvisionResult +{ + public required bool Succeeded { get; init; } + + public CredentialType? Type { get; init; } + + /// + /// Indicates whether existing security state was affected. + /// For initial provisioning this is usually false. + /// + public bool SecurityInvalidated { get; init; } + + public string? FailureReason { get; init; } + + /* ----------------- Helpers ----------------- */ + + public static CredentialProvisionResult Success(CredentialType type) + => new() + { + Succeeded = true, + Type = type, + SecurityInvalidated = false + }; + + public static CredentialProvisionResult AlreadyExists(CredentialType type) + => new() + { + Succeeded = true, + Type = type, + SecurityInvalidated = false + }; + + public static CredentialProvisionResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs new file mode 100644 index 0000000..e861b99 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs @@ -0,0 +1,33 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CredentialValidationResult( + bool IsValid, + bool RequiresReauthentication, + bool RequiresSecurityVersionIncrement, + string? FailureReason = null) + { + public static CredentialValidationResult Success( + bool requiresSecurityVersionIncrement = false) + => new( + IsValid: true, + RequiresReauthentication: false, + RequiresSecurityVersionIncrement: requiresSecurityVersionIncrement); + + public static CredentialValidationResult Failed( + string? reason = null, + bool requiresReauthentication = false) + => new( + IsValid: false, + RequiresReauthentication: requiresReauthentication, + RequiresSecurityVersionIncrement: false, + FailureReason: reason); + + public static CredentialValidationResult ReauthenticationRequired( + string? reason = null) + => new( + IsValid: false, + RequiresReauthentication: true, + RequiresSecurityVersionIncrement: false, + FailureReason: reason); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs new file mode 100644 index 0000000..c8018a5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResultDto.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialValidationResultDto +{ + public bool IsValid { get; init; } + + public bool RequiresReauthentication { get; init; } + public bool RequiresSecurityVersionIncrement { get; init; } + + public string? FailureReason { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj index e73e9e5..4ffa082 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -23,6 +23,9 @@ + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs index 382cb1f..7c91dd8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs @@ -2,11 +2,11 @@ namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore { - internal sealed class EfCoreAuthUser : IUser + internal sealed class EfCoreAuthUser : IAuthSubject { public TUserId UserId { get; } - IReadOnlyDictionary? IUser.Claims => null; + IReadOnlyDictionary? IAuthSubject.Claims => null; public EfCoreAuthUser(TUserId userId) { diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs index 8a6fb28..1ede89b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs @@ -1,83 +1,83 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; - -internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class -{ - private readonly DbContext _db; - private readonly CredentialUserMapping _map; - - public EfCoreUserStore(DbContext db, IOptions> options) - { - _db = db; - _map = CredentialUserMappingBuilder.Build(options.Value); - } - - public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new EfCoreAuthUser(_map.UserId(user)); - } - - public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new UserRecord - { - Id = _map.UserId(user), - Username = _map.Username(user), - PasswordHash = _map.PasswordHash(user), - IsActive = true, - CreatedAt = DateTimeOffset.UtcNow, - IsDeleted = false - }; - } - - public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); - - if (user is null || !_map.CanAuthenticate(user)) - return null; - - return new EfCoreAuthUser(_map.UserId(user)); - } - - public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - return _db.Set() - .Where(u => _map.UserId(u)!.Equals(userId)) - .Select(u => _map.PasswordHash(u)) - .FirstOrDefaultAsync(ct); - } - - public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) - { - throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + - "Use application-level user management services."); - } - - public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) - { - var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); - return user is null ? 0 : _map.SecurityVersion(user); - } - - public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) - { - throw new NotSupportedException("Security version updates must be handled by the application."); - } - -} +//using CodeBeam.UltimateAuth.Core.Abstractions; +//using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Infrastructure; +//using Microsoft.EntityFrameworkCore; +//using Microsoft.Extensions.Options; + +//namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +//internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class +//{ +// private readonly DbContext _db; +// private readonly CredentialUserMapping _map; + +// public EfCoreUserStore(DbContext db, IOptions> options) +// { +// _db = db; +// _map = CredentialUserMappingBuilder.Build(options.Value); +// } + +// public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new EfCoreAuthUser(_map.UserId(user)); +// } + +// public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new UserRecord +// { +// Id = _map.UserId(user), +// Username = _map.Username(user), +// PasswordHash = _map.PasswordHash(user), +// IsActive = true, +// CreatedAt = DateTimeOffset.UtcNow, +// IsDeleted = false +// }; +// } + +// public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); + +// if (user is null || !_map.CanAuthenticate(user)) +// return null; + +// return new EfCoreAuthUser(_map.UserId(user)); +// } + +// public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// return _db.Set() +// .Where(u => _map.UserId(u)!.Equals(userId)) +// .Select(u => _map.PasswordHash(u)) +// .FirstOrDefaultAsync(ct); +// } + +// public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) +// { +// throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + +// "Use application-level user management services."); +// } + +// public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) +// { +// var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); +// return user is null ? 0 : _map.SecurityVersion(user); +// } + +// public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) +// { +// throw new NotSupportedException("Security version updates must be handled by the application."); +// } + +//} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs index 3f7e4f1..060b866 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -12,7 +12,7 @@ public static IServiceCollection AddUltimateAuthEfCoreCredentials, EfCoreUserStore>(); + //services.AddScoped, EfCoreUserStore>(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj index a34b1a7..dbc070b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -10,6 +10,9 @@ + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs new file mode 100644 index 0000000..e244c1e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -0,0 +1,150 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.Reference; +using System.Collections.Concurrent; + +internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore, ICredentialSecurityVersionStore + where TUserId : notnull +{ + private readonly ConcurrentDictionary> _byLogin; + private readonly ConcurrentDictionary>> _byUser; + + private readonly IUAuthPasswordHasher _hasher; + private readonly IInMemoryUserIdProvider _userIdProvider; + + public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvider userIdProvider) + { + _hasher = hasher; + _userIdProvider = userIdProvider; + + _byLogin = new ConcurrentDictionary>( + StringComparer.OrdinalIgnoreCase); + + _byUser = new ConcurrentDictionary>>(); + + SeedDefault(); + } + + private void SeedDefault() + { + SeedUser("admin", _userIdProvider.GetAdminUserId()); + SeedUser("user", _userIdProvider.GetUserUserId()); + } + + private void SeedUser(string login, TUserId userId) + { + var state = new InMemoryPasswordCredentialState + { + UserId = userId, + Login = login, + SecretHash = _hasher.Hash(login), // admin/admin, user/user + Status = CredentialStatus.Active, + SecurityVersion = 0, + Metadata = new CredentialMetadata( + CreatedAt: DateTimeOffset.UtcNow, + LastUsedAt: null, + Source: "seed") + }; + + _byLogin[login] = state; + _byUser[userId] = new List> { state }; + } + + public Task?> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byLogin.TryGetValue(loginIdentifier, out var state)) + return Task.FromResult?>(null); + + if (state.Status != CredentialStatus.Active) + return Task.FromResult?>(null); + + return Task.FromResult?>(Map(state)); + } + + public Task>> GetByUserAsync(string? tenantId, TUserId userId, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byUser.TryGetValue(userId, out var list)) + return Task.FromResult>>(Array.Empty>()); + + var active = list + .Where(c => c.Status == CredentialStatus.Active) + .Select(Map) + .Cast>() + .ToArray(); + + return Task.FromResult>>(active); + } + + public Task UpdateSecretAsync(string? tenantId, TUserId userId, CredentialType type, string newSecretHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_byUser.TryGetValue(userId, out var list)) + return Task.CompletedTask; + + var state = list.FirstOrDefault(c => c.Type == type); + if (state is null) + return Task.CompletedTask; + + state.SecretHash = newSecretHash; + state.SecurityVersion++; + state.Metadata = state.Metadata with + { + LastUsedAt = DateTimeOffset.UtcNow + }; + + return Task.CompletedTask; + } + + // Security version + public Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + if (!_byUser.TryGetValue(userId, out var list)) + return Task.FromResult(0L); + + return Task.FromResult(list.Max(c => c.SecurityVersion)); + } + + public Task IncrementAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + if (_byUser.TryGetValue(userId, out var list)) + { + foreach (var c in list) + c.SecurityVersion++; + } + + return Task.CompletedTask; + } + + public Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_byUser.TryRemove(userId, out var list)) + { + foreach (var credential in list) + { + _byLogin.TryRemove(credential.Login, out _); + } + } + + return Task.CompletedTask; + } + + private static PasswordCredential Map( + InMemoryPasswordCredentialState state) + => new( + userId: state.UserId, + loginIdentifier: state.Login, + secretHash: state.SecretHash, + status: state.Status, + metadata: state.Metadata); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs deleted file mode 100644 index 0f090ea..0000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialUser.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal sealed class InMemoryCredentialUser : IUser - { - public UserKey UserId { get; init; } - public string Username { get; init; } - - public string PasswordHash { get; private set; } = default!; - - public long SecurityVersion { get; private set; } - - public bool IsActive { get; init; } = true; - - IReadOnlyDictionary? IUser.Claims => null; - - public InMemoryCredentialUser( - UserKey userId, - string username, - string passwordHash, - long securityVersion = 0, - bool isActive = true) - { - UserId = userId; - Username = username; - PasswordHash = passwordHash; - SecurityVersion = securityVersion; - IsActive = isActive; - } - - internal void SetPasswordHash(string passwordHash) - { - PasswordHash = passwordHash; - SecurityVersion++; - } - - internal void IncrementSecurityVersion() - { - SecurityVersion++; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs deleted file mode 100644 index 116896e..0000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialsSeeder.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal static class InMemoryCredentialSeeder - { - public static IReadOnlyCollection CreateDefaultUsers(IUAuthPasswordHasher passwordHasher) - { - var adminUserId = UserKey.New(); - var passwordHash = passwordHasher.Hash("Password!"); - - var admin = new InMemoryCredentialUser( - userId: adminUserId, - username: "Admin", - passwordHash: passwordHash, - securityVersion: 0, - isActive: true - ); - - return new[] { admin }; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs new file mode 100644 index 0000000..514cc4c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryPasswordCredentialState + { + public TUserId UserId { get; init; } = default!; + public CredentialType Type { get; } = CredentialType.Password; + public string Login { get; init; } = default!; + public string SecretHash { get; set; } = default!; + public CredentialStatus Status { get; set; } + public long SecurityVersion { get; set; } + public CredentialMetadata Metadata { get; set; } = default!; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialValidator.cs new file mode 100644 index 0000000..16b9ff2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialValidator.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory.Validation +{ + internal sealed class InMemoryPasswordCredentialValidator : ICredentialValidator + { + private readonly IUAuthPasswordHasher _hasher; + + public InMemoryPasswordCredentialValidator(IUAuthPasswordHasher hasher) + { + _hasher = hasher; + } + + public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (credential is not PasswordCredential pwd) + return Task.FromResult(CredentialValidationResult.Failed()); + + if (!pwd.IsActive) + return Task.FromResult(CredentialValidationResult.Failed()); + + var ok = _hasher.Verify(pwd.SecretHash, providedSecret); + + return Task.FromResult(ok + ? CredentialValidationResult.Success() + : CredentialValidationResult.Failed()); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs deleted file mode 100644 index 7b730c4..0000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryUserStore.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Collections.Concurrent; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - internal sealed class InMemoryUserStore : IUAuthUserStore - { - private readonly ConcurrentDictionary _usersByUsername; - private readonly ConcurrentDictionary _usersById; - - public InMemoryUserStore(IEnumerable seededUsers) - { - _usersByUsername = new ConcurrentDictionary( - StringComparer.OrdinalIgnoreCase); - - _usersById = new ConcurrentDictionary(); - - foreach (var user in seededUsers) - { - _usersByUsername[user.Username] = user; - _usersById[user.UserId] = user; - } - } - - public Task?> FindByIdAsync( - string? tenantId, - UserKey userId, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - _usersById.TryGetValue(userId, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); - } - - public Task?> FindByUsernameAsync( - string? tenantId, - string username, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!_usersByUsername.TryGetValue(username, out var user) || user.IsActive is false) - return Task.FromResult?>(null); - - // Core’daki UserRecord’u kullanıyorsun; InMemory tarafı buna map eder. - var record = new UserRecord - { - Id = user.UserId, - Username = user.Username, - PasswordHash = user.PasswordHash, - // ClaimsSnapshot varsa burada Empty bırakılabilir. - // Claims = ClaimsSnapshot.Empty, - RequiresMfa = false, - IsActive = user.IsActive, - CreatedAt = DateTimeOffset.UtcNow, - IsDeleted = false - }; - - return Task.FromResult?>(record); - } - - public Task?> FindByLoginAsync( - string? tenantId, - string login, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - _usersByUsername.TryGetValue(login, out var user); - return Task.FromResult?>(user is { IsActive: true } ? user : null); - } - - public Task GetPasswordHashAsync( - string? tenantId, - UserKey userId, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - return Task.FromResult( - _usersById.TryGetValue(userId, out var user) - ? user.PasswordHash - : null); - } - - public Task SetPasswordHashAsync( - string? tenantId, - UserKey userId, - string passwordHash, - CancellationToken token = default) - { - token.ThrowIfCancellationRequested(); - - if (_usersById.TryGetValue(userId, out var user)) - { - user.SetPasswordHash(passwordHash); - } - - return Task.CompletedTask; - } - - public Task GetSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) - { - return Task.FromResult( - _usersById.TryGetValue(userId, out var user) - ? user.SecurityVersion - : 0L); - } - - public Task IncrementSecurityVersionAsync(string? tenantId, UserKey userId, CancellationToken token = default) - { - if (_usersById.TryGetValue(userId, out var user)) - { - user.IncrementSecurityVersion(); - } - - return Task.CompletedTask; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs deleted file mode 100644 index b653258..0000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.Extensions.DependencyInjection; - -namespace CodeBeam.UltimateAuth.Credentials.InMemory -{ - public static class ServiceCollectionExtensions - { - /// - /// Registers the in-memory credential store with a default seeded user. - /// Intended for development, testing, and reference implementations. - /// - public static IServiceCollection AddInMemoryCredentials(this IServiceCollection services) - { - services.AddSingleton(sp => - { - var hasher = sp.GetService() - ?? throw new InvalidOperationException( - "IUAuthPasswordHasher is not registered. " + - "Call AddUltimateAuthArgon2() or register a custom hasher."); - - return InMemoryCredentialSeeder.CreateDefaultUsers(hasher); - }); - - services.AddSingleton>(sp => - { - var users = sp.GetRequiredService>(); - return new InMemoryUserStore(users); - }); - - return services; - } - } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs new file mode 100644 index 0000000..a174af4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.InMemory.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions +{ + public static class UltimateAuthCredentialsInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) + { + services.TryAddScoped(typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(typeof(ICredentialSecurityVersionStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddScoped(); + + return services; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj new file mode 100644 index 0000000..6b2038f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/CredentialMetadata.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/CredentialMetadata.cs new file mode 100644 index 0000000..5db2b76 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/CredentialMetadata.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed record CredentialMetadata( + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + string? Source); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs new file mode 100644 index 0000000..7cc06d8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class PasswordCredential : ILoginCredential, ISecretCredential +{ + public TUserId UserId { get; } + public CredentialType Type => CredentialType.Password; + public CredentialStatus Status { get; } + public string LoginIdentifier { get; } + public string SecretHash { get; } + public CredentialMetadata Metadata { get; } + + public bool IsActive => Status == CredentialStatus.Active; + + public PasswordCredential( + TUserId userId, + string loginIdentifier, + string secretHash, + CredentialStatus status, + CredentialMetadata metadata) + { + UserId = userId; + LoginIdentifier = loginIdentifier; + SecretHash = secretHash; + Status = status; + Metadata = metadata; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs new file mode 100644 index 0000000..e3d1d34 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs @@ -0,0 +1,63 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IUserCredentialsService _credentials; + + public DefaultCredentialEndpointHandler( + IAuthFlowContextAccessor authFlow, + IUserCredentialsService credentials) + { + _authFlow = authFlow; + _credentials = credentials; + } + + public async Task SetInitialAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var result = await _credentials.SetInitialAsync(flow.TenantId, flow.UserKey!.Value, req, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task ResetPasswordAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + await _credentials.ResetAsync(flow.TenantId, req.UserKey, req, ctx.RequestAborted); + + return Results.Ok(); + } + + public async Task RevokeAllAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + await _credentials.RevokeAllAsync(flow.TenantId, req, ctx.RequestAborted); + + return Results.Ok(); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3da31b0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Server.Endpoints; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Credentials.Reference +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs new file mode 100644 index 0000000..a4db59c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs @@ -0,0 +1,71 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class DefaultUserCredentialsService : IUserCredentialsService +{ + private readonly ICredentialStore _credentials; + private readonly ICredentialSecretStore _secrets; + private readonly ICredentialSecurityVersionStore _securityVersions; + private readonly IUAuthPasswordHasher _hasher; + private readonly IClock _clock; + + public DefaultUserCredentialsService( + ICredentialStore credentials, + ICredentialSecretStore secrets, + ICredentialSecurityVersionStore securityVersions, + IUAuthPasswordHasher hasher, + IClock clock) + { + _credentials = credentials; + _secrets = secrets; + _securityVersions = securityVersions; + _hasher = hasher; + _clock = clock; + } + + public async Task SetInitialAsync(string? tenantId, UserKey userKey, SetInitialCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var existing = await _credentials.GetByUserAsync(tenantId, userKey, ct); + if (existing.Any(c => c.Type == request.Type)) + return CredentialProvisionResult.AlreadyExists(request.Type); + + var hash = _hasher.Hash(request.Secret); + await _secrets.UpdateSecretAsync(tenantId, userKey, request.Type, hash, ct); + + return CredentialProvisionResult.Success(request.Type); + } + + public async Task ChangeAsync(string? tenantId, UserKey userKey, ChangeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var hash = _hasher.Hash(request.NewSecret); + + await _secrets.UpdateSecretAsync(tenantId, userKey, request.Type, hash, ct); + await _securityVersions.IncrementAsync(tenantId, userKey, ct); + + return ChangeCredentialResult.Success(request.Type); + } + + public async Task ResetAsync(string? tenantId, UserKey userKey, ResetPasswordRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var hash = _hasher.Hash(request.NewPassword); + + await _secrets.UpdateSecretAsync(tenantId, userKey, CredentialType.Password, hash, ct); + + await _securityVersions.IncrementAsync(tenantId, userKey, ct); + } + + public Task RevokeAllAsync(string? tenantId, RevokeAllCredentialsRequest request, CancellationToken ct = default) + => _securityVersions.IncrementAsync(tenantId, request.UserKey, ct); + + public Task DeleteAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + => _credentials.DeleteByUserAsync(tenantId, userKey, ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs new file mode 100644 index 0000000..84151c0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IUserCredentialsService +{ + /// + /// Sets the initial credential for a newly created user. + /// Fails if a credential of the same type already exists. + /// + Task SetInitialAsync(string? tenantId, UserKey userKey, SetInitialCredentialRequest request, CancellationToken ct = default); + Task ChangeAsync(string? tenantId, UserKey userKey, ChangeCredentialRequest request, CancellationToken ct = default); + Task ResetAsync(string? tenantId, UserKey userKey, ResetPasswordRequest request, CancellationToken ct = default); + Task RevokeAllAsync(string? tenantId, RevokeAllCredentialsRequest request, CancellationToken ct = default); + Task DeleteAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs new file mode 100644 index 0000000..f722a23 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredential +{ + TUserId UserId { get; } + CredentialType Type { get; } + bool IsActive { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs new file mode 100644 index 0000000..295256e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecretStore.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialSecretStore + { + Task UpdateSecretAsync(string? tenantId, TUserId userId, CredentialType type, string newSecretHash, CancellationToken ct = default); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecurityVersionStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecurityVersionStore.cs new file mode 100644 index 0000000..2a7eb42 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialSecurityVersionStore.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialSecurityVersionStore + { + Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + + Task IncrementAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs new file mode 100644 index 0000000..78f2cff --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Credentials +{ + public interface ICredentialStore + { + Task?> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default); + Task>> GetByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs new file mode 100644 index 0000000..9f1ccd2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialValidator +{ + Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs new file mode 100644 index 0000000..f81f5bf --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ILoginCredential.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ILoginCredential : ICredential +{ + string LoginIdentifier { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs new file mode 100644 index 0000000..ffc805e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ISecretCredential : ICredential +{ + string SecretHash { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj new file mode 100644 index 0000000..ed91b1a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + $(NoWarn);1591 + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs new file mode 100644 index 0000000..039e2ca --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/DefaultCredentialValidator.cs @@ -0,0 +1,28 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class DefaultCredentialValidator : ICredentialValidator +{ + private readonly IUAuthPasswordHasher _passwordHasher; + + public DefaultCredentialValidator(IUAuthPasswordHasher passwordHasher) => _passwordHasher = passwordHasher; + + public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) + { + if (credential is not ISecretCredential secret) + { + return Task.FromResult(new CredentialValidationResult( + IsValid: false, + RequiresReauthentication: false, + RequiresSecurityVersionIncrement: false, + FailureReason: "Unsupported credential type.")); + } + + var ok = _passwordHasher.Verify(secret.SecretHash, providedSecret); + + return Task.FromResult(ok + ? new CredentialValidationResult(true, false, false) + : new CredentialValidationResult(false, false, false, "Invalid credentials.")); + } +} diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index a277e09..6ddf7d4 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -30,9 +30,9 @@ public string Hash(string password) return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; } - public bool Verify(string password, string hash) + public bool Verify(string hash, string secret) { - if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hash)) + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash)) return false; var parts = hash.Split('.'); @@ -42,7 +42,7 @@ public bool Verify(string password, string hash) var salt = Convert.FromBase64String(parts[0]); var expectedHash = Convert.FromBase64String(parts[1]); - var argon2 = CreateArgon2(password, salt); + var argon2 = CreateArgon2(secret, salt); var actualHash = argon2.GetBytes(expectedHash.Length); return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs index bf6c491..cbce83d 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs @@ -1,14 +1,10 @@ using CodeBeam.UltimateAuth.Security.Argon2; -using CodeBeam.UltimateAuth.Server.Composition; -using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; public static class UltimateAuthServerBuilderArgon2Extensions { - public static UltimateAuthServerBuilder UseArgon2( - this UltimateAuthServerBuilder builder, - Action? configure = null) + public static UltimateAuthServerBuilder UseArgon2(this UltimateAuthServerBuilder builder, Action? configure = null) { builder.Services.AddUltimateAuthArgon2(configure); return builder; diff --git a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj similarity index 70% rename from src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj index 004336a..1f3e2de 100644 --- a/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj @@ -8,4 +8,8 @@ $(NoWarn);1591 + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs new file mode 100644 index 0000000..3a5b936 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public enum MfaMethod + { + Totp = 10, + Sms = 20, + Email = 30, + Passkey = 40 + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs new file mode 100644 index 0000000..04926b5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserAccessDecision.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserAccessDecision( + bool IsAllowed, + bool RequiresReauthentication, + string? DenyReason = null); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs new file mode 100644 index 0000000..0a0bc2d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserIdentifierDto + { + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public bool IsPrimary { get; init; } + public bool IsVerified { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs new file mode 100644 index 0000000..57ef44f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public enum UserIdentifierType + { + Username, + Email, + Phone + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs new file mode 100644 index 0000000..bf65278 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusDto.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserMfaStatusDto + { + public bool IsEnabled { get; init; } + public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); + public MfaMethod? DefaultMethod { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs new file mode 100644 index 0000000..45bf995 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs @@ -0,0 +1,25 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UserProfileDto + { + public string UserKey { get; init; } = default!; + + public string? UserName { get; init; } + public string? Email { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + + public string? Phone { get; init; } + + public bool EmailVerified { get; init; } + public bool PhoneVerified { get; init; } + + public UserStatus Status { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + public DateTimeOffset? LastLoginAt { get; init; } + + public IReadOnlyDictionary? Metadata { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs new file mode 100644 index 0000000..2a9f029 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInput.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserProfileInput +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs new file mode 100644 index 0000000..77f1c19 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs @@ -0,0 +1,29 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public enum UserStatus + { + // Normal state + Active = 0, + + // User initiated + SelfSuspended = 10, + + // Administrative actions + Disabled = 20, + Suspended = 30, + + // Security / risk based + Locked = 40, + RiskHold = 50, + + // Lifecycle + PendingActivation = 60, + PendingVerification = 70, + + // Terminal (soft-delete) + Deactivated = 80, + + // Soft // TODO: User domain already have IsDeleted, this may remove + Deleted = 90 + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs new file mode 100644 index 0000000..7c5c9e2 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/BeginMfaSetupRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record BeginMfaSetupRequest + { + public MfaMethod Method { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs new file mode 100644 index 0000000..cf6a530 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record ChangeUserIdentifierRequest + { + public required UserIdentifierType Type { get; init; } + public required string NewValue { get; init; } + public string? Reason { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs new file mode 100644 index 0000000..842a482 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed class ChangeUserStatusRequest + { + public required UserKey UserKey { get; init; } + public required UserStatus NewStatus { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs new file mode 100644 index 0000000..ab1ba70 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CompleteMfaSetupRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record CompleteMfaSetupRequest + { + public MfaMethod Method { get; init; } + public string VerificationCode { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs new file mode 100644 index 0000000..dc5fe86 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -0,0 +1,36 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record CreateUserRequest +{ + /// + /// Primary identifier (username, email, external id). + /// Interpretation is application-specific. + /// + public required string Identifier { get; init; } + + /// + /// Optional password. + /// If null, user may be invited or use external login. + /// + public string? Password { get; init; } + + public string? DisplayName { get; set; } + + public string? TenantId { get; init; } + + /// + /// Initial user status. + /// Defaults to Active. + /// + public UserStatus InitialStatus { get; init; } = UserStatus.Active; + + /// + /// Optional initial profile data. + /// + public UserProfileInput? Profile { get; init; } + + /// + /// Optional custom metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs new file mode 100644 index 0000000..33b9574 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record DeleteUserIdentifierRequest + { + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs new file mode 100644 index 0000000..0ed5ec4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed class DeleteUserRequest + { + public required UserKey UserKey { get; init; } + public DeleteMode Mode { get; init; } = DeleteMode.Soft; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs new file mode 100644 index 0000000..63da542 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DisableMfaRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record DisableMfaRequest + { + public MfaMethod? Method { get; init; } // null = all + } +} diff --git a/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs index 78cd996..e848244 100644 --- a/src/CodeBeam.UltimateAuth.Users/Contracts/RegisterUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Server.Users.Contracts +namespace CodeBeam.UltimateAuth.Users.Contracts { /// /// Request to register a new user with credentials. diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs new file mode 100644 index 0000000..3503699 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UpdateProfileRequest +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public string? PhoneNumber { get; init; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs new file mode 100644 index 0000000..5b642d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record VerifyUserIdentifierRequest + { + public required UserIdentifierType Type { get; init; } + public required string Code { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs new file mode 100644 index 0000000..0285c0a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record BeginMfaSetupResult + { + public MfaMethod Method { get; init; } + public string? SharedSecret { get; init; } // TOTP + public string? QrCodeUri { get; init; } // TOTP + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs new file mode 100644 index 0000000..a1e4b8e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record GetUserIdentifiersResult + { + public required IReadOnlyCollection Identifiers { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs new file mode 100644 index 0000000..db7376d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record IdentifierChangeResult + { + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierChangeResult Success() => new() { Succeeded = true }; + + public static IdentifierChangeResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs new file mode 100644 index 0000000..e62ec71 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record IdentifierDeleteResult + { + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierDeleteResult Success() => new() { Succeeded = true }; + public static IdentifierDeleteResult Fail(string reason) => new() { Succeeded = false, FailureReason = reason }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs new file mode 100644 index 0000000..6c4b446 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record IdentifierVerificationResult + { + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierVerificationResult Success() => new() { Succeeded = true }; + + public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs new file mode 100644 index 0000000..66d8ef3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserCreateResult +{ + public required bool Succeeded { get; init; } + + /// + /// Created user's key (string form of UserKey). + /// Available only when Succeeded = true. + /// + public string? UserKey { get; init; } + + public string? FailureReason { get; init; } + + public static UserCreateResult Success(UserKey userKey) + => new() + { + Succeeded = true, + UserKey = userKey.Value + }; + + public static UserCreateResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs new file mode 100644 index 0000000..52886f5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserDeleteResult +{ + public required bool Succeeded { get; init; } + + public required DeleteMode Mode { get; init; } + + public string? FailureReason { get; init; } + + public static UserDeleteResult Success(DeleteMode mode) + => new() + { + Succeeded = true, + Mode = mode + }; + + public static UserDeleteResult NotFound() + => new() + { + Succeeded = false, + Mode = DeleteMode.Soft, + FailureReason = "User not found." + }; + + public static UserDeleteResult AlreadyDeleted(DeleteMode mode) + => new() + { + Succeeded = true, + Mode = mode + }; + + public static UserDeleteResult Failed(DeleteMode mode, string reason) + => new() + { + Succeeded = false, + Mode = mode, + FailureReason = reason + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs new file mode 100644 index 0000000..e355cd9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -0,0 +1,42 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserStatusChangeResult +{ + public required bool Succeeded { get; init; } + + public UserStatus? PreviousStatus { get; init; } + + public UserStatus? CurrentStatus { get; init; } + + public string? FailureReason { get; init; } + + public static UserStatusChangeResult Success(UserStatus previous, UserStatus current) + => new() + { + Succeeded = true, + PreviousStatus = previous, + CurrentStatus = current + }; + + public static UserStatusChangeResult NoChange(UserStatus status) + => new() + { + Succeeded = true, + PreviousStatus = status, + CurrentStatus = status + }; + + public static UserStatusChangeResult NotFound() + => new() + { + Succeeded = false, + FailureReason = "User not found." + }; + + public static UserStatusChangeResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj new file mode 100644 index 0000000..fa0680d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs new file mode 100644 index 0000000..a30ecc5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Users.InMemory.Extensions +{ + public static class UltimateAuthUsersInMemoryExtensions + { + public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) + { + services.TryAddScoped, InMemoryUserStore>(); + services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddSingleton, InMemoryUserIdProvider>(); + + return services; + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs new file mode 100644 index 0000000..8ae70e3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserIdProvider.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + public sealed class InMemoryUserIdProvider : IInMemoryUserIdProvider + { + private static readonly UserKey Admin = UserKey.FromString("admin"); + private static readonly UserKey User = UserKey.FromString("user"); + + public UserKey GetAdminUserId() => Admin; + public UserKey GetUserUserId() => User; + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs new file mode 100644 index 0000000..bd7b9dd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSecurityStateProvider.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserSecurityStateProvider : IUserSecurityStateProvider + { + public Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + // InMemory default: no MFA, no lockout, no risk signals + return Task.FromResult(null); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs new file mode 100644 index 0000000..f0ba8dd --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -0,0 +1,133 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserIdentifierStore : IUserIdentifierStore + { + private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), List> _byUser = new(); + private readonly ConcurrentDictionary<(string? TenantId, UserIdentifierType Type, string Value), UserKey> _lookup = new(); + + public Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (_byUser.TryGetValue(key, out var list)) + return Task.FromResult>(list.ToArray()); + + return Task.FromResult>(Array.Empty()); + } + + public Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (_byUser.TryGetValue(key, out var list)) + { + var result = list.Where(x => x.Type == type).ToArray(); + return Task.FromResult>(result); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var userKeyTuple = (tenantId, userKey); + var lookupKey = (tenantId, record.Type, record.Value); + + var list = _byUser.GetOrAdd(userKeyTuple, _ => new List()); + + // replace if same type+value exists + var existingIndex = list.FindIndex(x => x.Type == record.Type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, record.Value)); + + if (existingIndex >= 0) + { + list[existingIndex] = record; + } + else + { + list.Add(record); + _lookup[lookupKey] = userKey; + } + + return Task.CompletedTask; + } + + public Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, userKey); + + if (!_byUser.TryGetValue(key, out var list)) + return Task.CompletedTask; + + for (int i = 0; i < list.Count; i++) + { + if (list[i].Type == type && !list[i].VerifiedAt.HasValue) + { + list[i] = list[i] with + { + VerifiedAt = verifiedAt + }; + } + } + + return Task.CompletedTask; + } + + public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (tenantId, type, value); + return Task.FromResult(_lookup.ContainsKey(key)); + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var userKeyTuple = (tenantId, userKey); + var lookupKey = (tenantId, type, value); + + if (!_byUser.TryGetValue(userKeyTuple, out var list)) + return Task.CompletedTask; + + var index = list.FindIndex(x => x.Type == type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, value)); + + if (index < 0) + return Task.CompletedTask; + + var record = list[index]; + + if (mode == DeleteMode.Soft) + { + if (record.DeletedAt.HasValue) + return Task.CompletedTask; + + list[index] = record with + { + DeletedAt = DateTimeOffset.UtcNow + }; + } + else + { + list.RemoveAt(index); + _lookup.TryRemove(lookupKey, out _); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs new file mode 100644 index 0000000..dcc7e48 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -0,0 +1,157 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Domain; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore +{ + private readonly ConcurrentDictionary _users = new(); + private readonly IInMemoryUserIdProvider _idProvider; + private readonly IClock _clock; + + public InMemoryUserLifecycleStore(IInMemoryUserIdProvider idProvider, IClock clock) + { + _idProvider = idProvider; + _clock = clock; + SeedDefault(); + } + + private void SeedDefault() + { + CreateSeedUser(_idProvider.GetAdminUserId(), "admin"); + CreateSeedUser(_idProvider.GetUserUserId(), "user"); + } + + private void CreateSeedUser(UserKey userKey, string identifier) + { + var now = _clock.UtcNow; + + var profile = new ReferenceUserProfile + { + UserKey = userKey, + Email = identifier, + DisplayName = identifier == "admin" + ? "Administrator" + : "Standard User", + Status = UserStatus.Active, + IsDeleted = false, + CreatedAt = now, + UpdatedAt = now, + DeletedAt = null + }; + + _users.TryAdd( + new UserIdentity(null, userKey), + profile); + } + + public Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(user); + + var identity = new UserIdentity(tenantId, user.UserKey); + + if (!_users.TryAdd(identity, InitializeUser(user))) + { + throw new InvalidOperationException( + $"User '{user.UserKey}' already exists in tenant '{tenantId ?? ""}'."); + } + + return Task.CompletedTask; + } + + public Task UpdateStatusAsync( + string? tenantId, + UserKey userKey, + UserStatus status, + CancellationToken ct = default) + { + var identity = new UserIdentity(tenantId, userKey); + + if (!_users.TryGetValue(identity, out var user)) + throw new InvalidOperationException($"User '{userKey}' does not exist."); + + if (user.IsDeleted) + throw new InvalidOperationException($"User '{userKey}' is deleted."); + + if (user.Status == status) + return Task.CompletedTask; // idempotent + + if (!IsValidStatusTransition(user.Status, status)) + throw new InvalidOperationException( + $"Invalid status transition from '{user.Status}' to '{status}'."); + + user.Status = status; + user.UpdatedAt = DateTimeOffset.UtcNow; + + return Task.CompletedTask; + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var identity = new UserIdentity(tenantId, userKey); + + if (!_users.TryGetValue(identity, out var user)) + { + return Task.CompletedTask; + } + + switch (mode) + { + case DeleteMode.Soft: + if (user.IsDeleted) + return Task.CompletedTask; + + user.Status = UserStatus.Deleted; + user.IsDeleted = true; + user.DeletedAt = at; + user.UpdatedAt = at; + break; + + case DeleteMode.Hard: + _users.TryRemove(identity, out _); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown delete mode."); + } + + return Task.CompletedTask; + } + + private static ReferenceUserProfile InitializeUser(ReferenceUserProfile user) + { + return user with + { + Status = user.Status == default ? UserStatus.Active : user.Status, + CreatedAt = user.CreatedAt == default ? DateTimeOffset.UtcNow : user.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + IsDeleted = false, + DeletedAt = null + }; + } + + private static bool IsValidStatusTransition(UserStatus from, UserStatus to) + { + return from switch + { + UserStatus.Active => to is UserStatus.Suspended or UserStatus.Disabled, + UserStatus.Suspended => to is UserStatus.Active or UserStatus.Disabled, + UserStatus.Disabled => to is UserStatus.Active, + _ => false + }; + } + + private readonly record struct UserIdentity(string? TenantId, UserKey UserKey); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs new file mode 100644 index 0000000..d84740c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -0,0 +1,142 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Users.Reference.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserProfileStore : IUserProfileStore +{ + private readonly ConcurrentDictionary _profiles = new(); + private readonly IInMemoryUserIdProvider _idProvider; + private readonly IClock _clock; + + public InMemoryUserProfileStore(IInMemoryUserIdProvider idProvider, IClock clock) + { + _idProvider = idProvider; + _clock = clock; + SeedDefault(); + } + + private void SeedDefault() + { + SeedProfile(_idProvider.GetAdminUserId(), "Administrator"); + SeedProfile(_idProvider.GetUserUserId(), "Standard User"); + } + + private void SeedProfile(UserKey userKey, string displayName) + { + var now = _clock.UtcNow; + + _profiles[userKey] = new ReferenceUserProfile + { + UserKey = userKey, + DisplayName = displayName, + Status = UserStatus.Active, + CreatedAt = now, + UpdatedAt = now + }; + } + + public Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var mem = new ReferenceUserProfile + { + UserKey = profile.UserKey, + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Email = profile.Email, + Status = profile.Status, + IsDeleted = profile.IsDeleted, + CreatedAt = profile.CreatedAt, + UpdatedAt = profile.UpdatedAt, + DeletedAt = profile.DeletedAt + }; + + if (!_profiles.TryAdd(profile.UserKey, mem)) + throw new InvalidOperationException($"User profile '{profile.UserKey}' already exists."); + + return Task.CompletedTask; + } + + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) + return Task.FromResult(null); + + return Task.FromResult(Map(profile)); + } + + public Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) + throw new InvalidOperationException("User profile does not exist."); + + profile.FirstName = request.FirstName; + profile.LastName = request.LastName; + profile.DisplayName = request.DisplayName; + profile.UpdatedAt = DateTimeOffset.UtcNow; + + return Task.CompletedTask; + } + + public Task SetStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) + throw new InvalidOperationException("User profile does not exist."); + + profile.Status = status; + profile.UpdatedAt = DateTimeOffset.UtcNow; + + return Task.CompletedTask; + } + + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (mode == DeleteMode.Hard) + { + _profiles.TryRemove(userKey, out _); + return Task.CompletedTask; + } + + if (!_profiles.TryGetValue(userKey, out var profile)) + throw new InvalidOperationException("User profile does not exist."); + + profile.IsDeleted = true; + profile.Status = UserStatus.Deleted; + profile.DeletedAt = DateTimeOffset.UtcNow; + profile.UpdatedAt = profile.DeletedAt; + + return Task.CompletedTask; + } + + private static ReferenceUserProfile Map(ReferenceUserProfile profile) + => new() + { + UserKey = profile.UserKey, + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Email = profile.Email, + Status = profile.Status, + CreatedAt = profile.CreatedAt, + UpdatedAt = profile.UpdatedAt, + IsDeleted = profile.IsDeleted, + DeletedAt = profile.DeletedAt + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs new file mode 100644 index 0000000..8e5ba55 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs @@ -0,0 +1,80 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserStore : IUserStore +{ + private readonly ConcurrentDictionary _users; + private readonly ConcurrentDictionary _userKeysByLogin; + + public InMemoryUserStore(IEnumerable? seededUsers = null) + { + _users = new ConcurrentDictionary(); + _userKeysByLogin = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + if (seededUsers is null) + return; + + foreach (var user in seededUsers) + { + _users[user.UserKey] = user; + + if (!string.IsNullOrWhiteSpace(user.Email)) + _userKeysByLogin[user.Email] = user.UserKey; + } + } + + public Task?> FindByIdAsync(string? tenantId, UserKey userId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_users.TryGetValue(userId, out var user)) + return Task.FromResult?>(null); + + if (user.IsDeleted) + return Task.FromResult?>(null); + + return Task.FromResult?>(Map(user)); + } + + public Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_userKeysByLogin.TryGetValue(login, out var userKey)) + return Task.FromResult?>(null); + + if (!_users.TryGetValue(userKey, out var user)) + return Task.FromResult?>(null); + + if (user.IsDeleted) + return Task.FromResult?>(null); + + return Task.FromResult?>(Map(user)); + } + + public Task ExistsAsync(string? tenantId, UserKey userId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult( + _users.TryGetValue(userId, out var user) && !user.IsDeleted); + } + + private static AuthUserRecord Map(ReferenceUserProfile user) + { + return new AuthUserRecord + { + Id = user.UserKey, + Identifier = user.Email ?? user.DisplayName ?? string.Empty, + IsActive = user.Status == UserStatus.Active, + IsDeleted = user.IsDeleted, + CreatedAt = user.CreatedAt, + DeletedAt = user.DeletedAt + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj new file mode 100644 index 0000000..d64f898 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs new file mode 100644 index 0000000..727bca1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/ChangeUserIdentifierCommand.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class ChangeUserIdentifierCommand : IAccessCommand +{ + private readonly Func _execute; + private readonly IEnumerable _policies; + + public ChangeUserIdentifierCommand(IEnumerable policies, Func execute) + { + _policies = policies ?? Array.Empty(); + _execute = execute; + } + + public IEnumerable GetPolicies(AccessContext context) => _policies; + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs new file mode 100644 index 0000000..0019d7d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference.Domain; + +public sealed record class ReferenceUserProfile +{ + public UserKey UserKey { get; init; } = default!; + + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? DisplayName { get; set; } + public string? Email { get; init; } + public string? Phone { get; init; } + + public UserStatus Status { get; set; } = UserStatus.Active; + + public bool IsDeleted { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs new file mode 100644 index 0000000..292d181 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed record UserIdentifierRecord + { + public UserKey UserKey { get; init; } + + public UserIdentifierType Type { get; init; } // Email, Phone, Username + public string Value { get; init; } = default!; + + public bool IsPrimary { get; init; } + public bool IsVerified { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } + public DateTimeOffset? DeletedAt { get; init; } + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs new file mode 100644 index 0000000..20389b9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs @@ -0,0 +1,66 @@ +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed class DefaultUserEndpointHandler : IUserEndpointHandler + { + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IUserLifecycleService _lifecycle; + + public DefaultUserEndpointHandler(IAuthFlowContextAccessor authFlow, IUserLifecycleService lifecycle) + { + _authFlow = authFlow; + _lifecycle = lifecycle; + } + + public async Task CreateAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var result = await _lifecycle.CreateAsync(flow.TenantId, req, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task ChangeStatusAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var result = await _lifecycle.ChangeStatusAsync(flow.TenantId, req, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task DeleteAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var result = await _lifecycle.DeleteAsync(flow.TenantId, req,ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs new file mode 100644 index 0000000..ef4bea3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Reference.Endpoints +{ + public sealed class DefaultUserProfileEndpointHandler : IUserProfileEndpointHandler + { + private readonly IUAuthUserProfileService _profiles; + + public Task GetAsync(HttpContext ctx) + { + throw new NotImplementedException(); + } + + //public Task GetAsync(HttpContext ctx) + //=> Results.Ok(await _profiles.GetCurrentAsync()); + + public async Task UpdateAsync(HttpContext ctx) + { + //var req = await ctx.ReadJsonAsync(); + //await _profiles.UpdateProfileAsync(...); + return Results.NoContent(); + } + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs new file mode 100644 index 0000000..72d3244 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthUsersReference(this IServiceCollection services) + { + services.PostConfigure(_ => + { + // Marker only – runtime validation happens via DI resolution + }); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + + private sealed class UsersReferenceMarker; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs new file mode 100644 index 0000000..e1184ec --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public static class UserIdentifierMapper + { + public static UserIdentifierDto ToDto(UserIdentifierRecord record) + => new() + { + Type = record.Type, + Value = record.Value, + IsPrimary = record.IsPrimary, + IsVerified = record.IsVerified, + CreatedAt = record.CreatedAt, + VerifiedAt = record.VerifiedAt + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs new file mode 100644 index 0000000..9fe7fb9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal static class UserProfileMapper +{ + public static UserProfileDto ToDto(ReferenceUserProfile profile) + => new() + { + UserKey = profile.UserKey.ToString(), + FirstName = profile.FirstName, + LastName = profile.LastName, + Email = profile.Email, + Phone = profile.Phone, + Status = profile.Status, + Metadata = profile.Metadata + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs new file mode 100644 index 0000000..33524b8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs @@ -0,0 +1,132 @@ +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; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class DefaultUserIdentifierService : IUserIdentifierService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserIdentifierStore _store; + private readonly UAuthServerOptions _serverOptions; + private readonly IClock _clock; + + public DefaultUserIdentifierService(IAccessOrchestrator accessOrchestrator, IUserIdentifierStore store, IOptions serverOptions, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _store = store; + _serverOptions = serverOptions.Value; + _clock = clock; + } + + public async Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var records = await _store.GetAllAsync(tenantId, userKey, ct); + var dtos = records.Where(r => r.DeletedAt is null).Select(UserIdentifierMapper.ToDto).ToArray(); + + return new GetUserIdentifiersResult + { + Identifiers = dtos + }; + } + + public async Task ChangeAsync(string? tenantId, UserKey userKey, ChangeUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var ctx = new AccessContext + { + TenantId = tenantId, + ActorUserKey = userKey, + Action = "users.identifiers.change", + Resource = "users", + ResourceId = userKey.Value, + }; + + var policies = new IAccessPolicy[] + { + + }; + + var record = new UserIdentifierRecord + { + Type = request.Type, + Value = request.NewValue, + IsVerified = false, + CreatedAt = _clock.UtcNow, + VerifiedAt = null, + DeletedAt = null + }; + + var cmd = new ChangeUserIdentifierCommand( + policies, + async innerCt => + { + // Domain rule: uniqueness + var exists = await _store.ExistsAsync(tenantId, request.Type, request.NewValue, innerCt); + + if (exists) + throw new InvalidOperationException("identifier_already_exists"); + + // Main job + await _store.SetAsync(tenantId, userKey, record, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(ctx, cmd, ct); + + return IdentifierChangeResult.Success(); + + + //var exists = await _store.ExistsAsync(tenantId, request.Type, request.NewValue, ct); + + //if (exists) + //{ + // return IdentifierChangeResult.Failed("identifier_already_exists"); + //} + + //var record = new UserIdentifierRecord + //{ + // Type = request.Type, + // Value = request.NewValue, + // IsVerified = false, + // CreatedAt = _clock.UtcNow, + // VerifiedAt = null, + // DeletedAt = null + //}; + + //await _store.SetAsync(tenantId, userKey, record, ct); + + //return IdentifierChangeResult.Success(); + } + + public async Task VerifyAsync(string? tenantId, UserKey userKey, VerifyUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + await _store.MarkVerifiedAsync(tenantId, userKey, request.Type, _clock.UtcNow, ct); + return IdentifierVerificationResult.Success(); + } + + public async Task DeleteAsync(string? tenantId, UserKey userKey, DeleteUserIdentifierRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var identifiers = await _store.GetByTypeAsync(tenantId, userKey, request.Type, ct); + var activeCount = identifiers.Count(i => i.DeletedAt is null); + + if (activeCount <= 1 && request.Type == UserIdentifierType.Username) + { + return IdentifierDeleteResult.Fail("last_username_cannot_be_deleted"); + } + + await _store.DeleteAsync(tenantId, userKey, request.Type, request.Value, request.Mode,ct); + + return IdentifierDeleteResult.Success(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs new file mode 100644 index 0000000..3e59922 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs @@ -0,0 +1,153 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class DefaultUserLifecycleService : IUserLifecycleService + { + private readonly IUserStore _users; + private readonly IUserProfileStore _profiles; + private readonly IUserLifecycleStore _userLifecycleStore; + private readonly IUserCredentialsService _credentials; + private readonly ISessionService _sessionService; + private readonly IAuthContextFactory _authContextFactory; + private readonly IClock _clock; + + public DefaultUserLifecycleService( + IUserStore users, + IUserProfileStore profiles, + IUserLifecycleStore userLifecycleStore, + IUserCredentialsService credentials, + ISessionService sessionService, + IAuthContextFactory authContextFactory, + IClock clock) + { + _users = users; + _profiles = profiles; + _userLifecycleStore = userLifecycleStore; + _credentials = credentials; + _sessionService = sessionService; + _authContextFactory = authContextFactory; + _clock = clock; + } + + public async Task CreateAsync(string? tenantId, CreateUserRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(request.Identifier)) + return UserCreateResult.Failed("Identifier is required."); + + var existing = await _users.FindByLoginAsync(tenantId, request.Identifier, ct); + if (existing is not null) + return UserCreateResult.Failed("User already exists."); + + var userKey = UserKey.New(); + var now = _clock.UtcNow; + + var profile = new ReferenceUserProfile + { + UserKey = userKey, + Email = request.Identifier, + DisplayName = request.DisplayName, + Status = UserStatus.Active, + IsDeleted = false, + CreatedAt = now, + UpdatedAt = now, + DeletedAt = null, + FirstName = request?.Profile?.FirstName, + LastName = request?.Profile?.LastName, + }; + + await _userLifecycleStore.CreateAsync(tenantId, profile, ct); + await _profiles.CreateAsync(tenantId, profile, ct); + + + if (!string.IsNullOrWhiteSpace(request?.Password)) + { + await _credentials.SetInitialAsync( + tenantId, + userKey, + new SetInitialCredentialRequest + { + Type = CredentialType.Password, + Secret = request.Password + }); + } + + return UserCreateResult.Success(userKey); + } + + public async Task ChangeStatusAsync(string? tenantId, ChangeUserStatusRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var exists = await _users.ExistsAsync(tenantId, request.UserKey, ct); + if (!exists) + return UserStatusChangeResult.NotFound(); + + var profile = await _profiles.GetAsync(tenantId, request.UserKey, ct); + if (profile is null) + return UserStatusChangeResult.NotFound(); + + var oldStatus = profile.Status; + if (oldStatus == request.NewStatus) + return UserStatusChangeResult.NoChange(oldStatus); + + await _profiles.SetStatusAsync(tenantId, request.UserKey, request.NewStatus, ct); + + // TODO: Check all status + if (request.NewStatus is UserStatus.Disabled or UserStatus.Suspended) + { + await _credentials.RevokeAllAsync(tenantId, new RevokeAllCredentialsRequest { UserKey = request.UserKey }, ct); + } + + return UserStatusChangeResult.Success(oldStatus, request.NewStatus); + } + + public async Task DeleteAsync(string? tenantId, DeleteUserRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var user = await _users.FindByIdAsync(tenantId, request.UserKey, ct); + if (user is null) + return UserDeleteResult.NotFound(); + + var authContext = _authContextFactory.Create(); + if (request.Mode == DeleteMode.Soft) + { + if (user.IsDeleted) + return UserDeleteResult.AlreadyDeleted(DeleteMode.Soft); + + await _userLifecycleStore.DeleteAsync(tenantId, request.UserKey, request.Mode, _clock.UtcNow, ct); + await _credentials.RevokeAllAsync(tenantId, new RevokeAllCredentialsRequest { UserKey = request.UserKey }, ct); + await _sessionService.RevokeAllAsync(authContext, request.UserKey, ct); + + return UserDeleteResult.Success(DeleteMode.Soft); + } + + // Hard delete + if (user.IsDeleted == false) + { + // Optional safety: require soft-delete first + await _userLifecycleStore.DeleteAsync(tenantId, request.UserKey, DeleteMode.Soft, _clock.UtcNow, ct); + } + + await _sessionService.RevokeAllAsync(authContext, request.UserKey, ct); + await _credentials.DeleteAllAsync(tenantId, request.UserKey, ct); + await _profiles.DeleteAsync(tenantId, request.UserKey, request.Mode, ct); + await _userLifecycleStore.DeleteAsync(tenantId, request.UserKey, request.Mode, _clock.UtcNow, ct); + + return UserDeleteResult.Success(DeleteMode.Hard); + } + + + + + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs new file mode 100644 index 0000000..32e8d27 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class DefaultUserProfileService : IUAuthUserProfileService +{ + private readonly IUserProfileStore _profiles; + private readonly ICurrentUser _currentUser; + + public DefaultUserProfileService(IUserProfileStore profiles, ICurrentUser currentUser) + { + _profiles = profiles; + _currentUser = currentUser; + } + + public async Task GetCurrentAsync(string? tenantId, CancellationToken ct = default) + { + if (!_currentUser.IsAuthenticated) + throw new UnauthorizedAccessException(); + + var profile = await _profiles.GetAsync(tenantId, _currentUser.UserKey, ct) ?? throw new InvalidOperationException("User profile not found."); + + return UserProfileMapper.ToDto(profile); + } + + public Task UpdateProfileAsync(string? tenantId, UpdateProfileRequest request, CancellationToken ct = default) + { + return _profiles.UpdateAsync(tenantId, _currentUser.UserKey, request, ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs new file mode 100644 index 0000000..213776c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserIdentifierService + { + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task ChangeAsync(string? tenantId, UserKey userKey, ChangeUserIdentifierRequest request, CancellationToken ct = default); + Task VerifyAsync(string? tenantId, UserKey userKey, VerifyUserIdentifierRequest request, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteUserIdentifierRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs new file mode 100644 index 0000000..eac939a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserLifecycleService +{ + Task CreateAsync(string? tenantId, CreateUserRequest request, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, DeleteUserRequest request, CancellationToken ct = default); + Task ChangeStatusAsync(string? tenantId, ChangeUserStatusRequest request, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs new file mode 100644 index 0000000..bd1e076 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUAuthUserProfileService + { + Task GetCurrentAsync(string? tenantId, CancellationToken ct = default); + Task UpdateProfileAsync(string? tenantId, UpdateProfileRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs new file mode 100644 index 0000000..f07c2dc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserIdentifierStore + { + Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default); + Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default); + Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs new file mode 100644 index 0000000..15f279e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserLifecycleStore + { + Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default); + Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, 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 new file mode 100644 index 0000000..7d51a43 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserProfileStore +{ + // TODO: Do CreateAsync internal with initializer service + Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default); + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default); + Task SetStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default); + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs new file mode 100644 index 0000000..b4a9348 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUser + { + TUserId UserId { get; } + bool IsActive { get; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs new file mode 100644 index 0000000..4c1e0cb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents +{ + Task OnUserActivatedAsync(TUserId userId); + Task OnUserDeactivatedAsync(TUserId userId); + Task OnSecurityInvalidatedAsync(TUserId userId); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs new file mode 100644 index 0000000..f6b6547 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityState.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityState +{ + long SecurityVersion { get; } + bool IsLocked { get; } + bool RequiresReauthentication { get; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs new file mode 100644 index 0000000..f001bef --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityStateProvider.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUserSecurityStateProvider + { + Task GetAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs new file mode 100644 index 0000000..1534c02 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users +{ + public interface IUserStore + { + /// + /// Finds a user by its application-level user id. + /// Returns null if the user does not exist or is deleted. + /// + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + + /// + /// Finds a user by a login identifier (username, email, etc). + /// Used during login discovery phase. + /// + Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default); + + /// + /// Checks whether a user exists. + /// Fast-path helper for authorities. + /// + Task ExistsAsync(string? tenantId, TUserId userId, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj new file mode 100644 index 0000000..66f9de3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -0,0 +1,19 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + 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 145cd30..e30fd8c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -18,17 +18,26 @@ + + + - + + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs new file mode 100644 index 0000000..a5d74ae --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Globalization; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + public sealed class UserIdConverterTests + { + [Fact] + public void UserKey_Roundtrip_Should_Preserve_Value() + { + var key = UserKey.New(); + var converter = new UAuthUserIdConverter(); + + var str = converter.ToString(key); + var parsed = converter.FromString(str); + + Assert.Equal(key, parsed); + } + + [Fact] + public void Guid_Roundtrip_Should_Work() + { + var id = Guid.NewGuid(); + var converter = new UAuthUserIdConverter(); + + var str = converter.ToString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void String_Roundtrip_Should_Work() + { + var id = "user_123"; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void Int_Should_Use_Invariant_Culture() + { + var id = 1234; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToString(id); + + Assert.Equal(id.ToString(CultureInfo.InvariantCulture), str); + } + + [Fact] + public void Long_Roundtrip_Should_Work() + { + var id = 9_223_372_036_854_775_000L; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void Double_UserId_Should_Throw() + { + var converter = new UAuthUserIdConverter(); + + Assert.ThrowsAny(() => converter.ToString(12.34)); + } + + private sealed class CustomUserId + { + public string Value { get; set; } = "x"; + } + + [Fact] + public void Custom_UserId_Should_Fail() + { + var converter = new UAuthUserIdConverter(); + + Assert.ThrowsAny(() => converter.ToString(new CustomUserId())); + } + + [Fact] + public void UserKey_Json_Serialization_Should_Be_String() + { + var key = UserKey.New(); + + var json = JsonSerializer.Serialize(key); + var roundtrip = JsonSerializer.Deserialize(json); + + Assert.Equal(key, roundtrip); + } + + } +}