diff --git a/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs index 5d285880..3dde6696 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Seed/UserSeedContributor.cs @@ -59,11 +59,11 @@ await lifecycleStore.AddAsync( ct); } - var profileKey = new UserProfileKey(tenant, userKey); + var profileKey = new UserProfileKey(tenant, userKey, ProfileKey.Default); if (!await profileStore.ExistsAsync(profileKey, ct)) { await profileStore.AddAsync( - UserProfile.Create(Guid.NewGuid(), tenant, userKey, now, displayName: displayName), + UserProfile.Create(Guid.NewGuid(), tenant, userKey, ProfileKey.Default, now, displayName: displayName), ct); } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 9108573e..9784acb8 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -38,6 +38,7 @@ o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); o.Identifiers.AllowMultipleUsernames = true; + o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthInMemory() .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs index e6807ab6..3f963991 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs @@ -144,7 +144,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr Mode = DeleteMode.Soft }; - var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + var result = await UAuthClient.Users.DeleteUserAsync(user.UserKey, req); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs new file mode 100644 index 00000000..5077af89 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.Designer.cs @@ -0,0 +1,716 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20260405222857_MultiProfile")] + partial class MultiProfile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialType") + .HasColumnType("INTEGER"); + + b.Property("FailedAttempts") + .HasColumnType("INTEGER"); + + b.Property("LastFailedAt") + .HasColumnType("TEXT"); + + b.Property("LockedUntil") + .HasColumnType("TEXT"); + + b.Property("RequiresReauthentication") + .HasColumnType("INTEGER"); + + b.Property("ResetAttempts") + .HasColumnType("INTEGER"); + + b.Property("ResetConsumedAt") + .HasColumnType("TEXT"); + + b.Property("ResetExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ResetRequestedAt") + .HasColumnType("TEXT"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ActiveSessionId") + .HasColumnType("TEXT"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasColumnType("TEXT"); + + b.Property("RotationCount") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TouchCount") + .HasColumnType("INTEGER"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenId") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("VerifiedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs new file mode 100644 index 00000000..049933f1 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260405222857_MultiProfile.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class MultiProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.AddColumn( + name: "ProfileKey", + table: "UAuth_UserProfiles", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey", "ProfileKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.DropColumn( + name: "ProfileKey", + table: "UAuth_UserProfiles"); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey" }); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index 211ef12e..af13077f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -655,6 +655,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Metadata") .HasColumnType("TEXT"); + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("Tenant") .IsRequired() .HasMaxLength(128) @@ -677,7 +682,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("Tenant", "UserKey"); + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); b.ToTable("UAuth_UserProfiles", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm index 9c25688e..0c7cbb9c 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal index 42498f65..03c676f0 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs index 3168085d..5d184fe7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs @@ -144,7 +144,7 @@ Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.Pr Mode = DeleteMode.Soft }; - var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + var result = await UAuthClient.Users.DeleteUserAsync(user.UserKey, req); if (result.IsSuccess) { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs index b951e1ec..7c057ff2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -15,8 +15,8 @@ public sealed record PkceCompleteRequest public required string Secret { get; init; } [JsonPropertyName("return_url")] - public string ReturnUrl { get; init; } + public string? ReturnUrl { get; init; } [JsonPropertyName("hub_session_id")] - public string HubSessionId { get; init; } + public string? HubSessionId { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 58fc735b..337cc47c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -68,8 +68,12 @@ public static class UserProfiles public const string GetSelf = "users.profile.get.self"; public const string UpdateSelf = "users.profile.update.self"; + public const string CreateSelf = "users.profile.add.self"; + public const string CreateAdmin = "users.profile.add.admin"; public const string GetAdmin = "users.profile.get.admin"; public const string UpdateAdmin = "users.profile.update.admin"; + public const string DeleteSelf = "users.profile.delete.self"; + public const string DeleteAdmin = "users.profile.delete.admin"; } public static class UserIdentifiers diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index 0e0ab036..f46ced1f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -10,7 +10,7 @@ public static SessionValidationResult ToDomain(SessionValidationInfo dto) { var state = (SessionState)dto.State; - if (!dto.IsValid || dto.Snapshot.Identity is null) + if (!dto.IsValid || dto.Snapshot?.Identity is null) { return SessionValidationResult.Invalid(state); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 33881151..9d774c7d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -38,7 +38,7 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); if (options.OpaqueIdBytes > 128) - errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); } return errors.Count == 0 diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs index 76e977f3..cf182877 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Auth { @@ -23,7 +24,7 @@ public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUser return null; var identifiers = await _identifier.GetAsync(validation.Tenant, validation.UserKey.Value, ct); - var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ProfileKey.Default, ct); var lifecycle = await _lifecycle.GetAsync(validation.Tenant, validation.UserKey.Value, ct); var identity = new AuthIdentitySnapshot diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs index 147b87dc..45491c25 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs @@ -12,13 +12,13 @@ public UAuthPolicyProvider(IOptions options) _fallback = new DefaultAuthorizationPolicyProvider(options); } - public Task GetPolicyAsync(string policyName) + public Task GetPolicyAsync(string policyName) { var policy = new AuthorizationPolicyBuilder() .AddRequirements(new UAuthActionRequirement(policyName)) .Build(); - return Task.FromResult(policy); + return Task.FromResult(policy); } public Task GetDefaultPolicyAsync() diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index 8e43a97b..fa21263e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -19,6 +19,12 @@ public interface IUserEndpointHandler Task GetUserAsync(UserKey userKey, HttpContext ctx); Task UpdateUserAsync(UserKey userKey, HttpContext ctx); + Task CreateProfileSelfAsync(HttpContext ctx); + Task DeleteProfileSelfAsync(HttpContext ctx); + + Task CreateProfileAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteProfileAdminAsync(UserKey userKey, HttpContext ctx); + Task GetMyIdentifiersAsync(HttpContext ctx); Task IdentifierExistsSelfAsync(HttpContext ctx); Task AddUserIdentifierSelfAsync(HttpContext ctx); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index fa1d0120..4ba6e369 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -220,21 +220,37 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.UserProfile != false) { if (Enabled(UAuthActions.UserProfiles.GetSelf)) - self.MapPost("/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/profile/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.CreateSelf)) + self.MapPost("/profile/create", async (IUserEndpointHandler h, HttpContext ctx) + => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) - self.MapPost("/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + self.MapPost("/profile/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.DeleteSelf)) + self.MapPost("/profile/delete", async (IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.GetAdmin)) adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.CreateAdmin)) + adminUsers.MapPost("/{userKey}/profile/create", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + if (Enabled(UAuthActions.UserProfiles.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/profile/delete", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } if (options.Endpoints.UserIdentifier != false) diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 6706f0d4..97652d24 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -95,6 +95,8 @@ public sealed class UAuthServerOptions public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); + public UAuthUserProfileOptions UserProfile { get; set; } = new(); + public UAuthNavigationOptions Navigation { get; set; } = new(); @@ -148,6 +150,7 @@ internal UAuthServerOptions Clone() Identifiers = Identifiers.Clone(), IdentifierValidation = IdentifierValidation.Clone(), LoginIdentifiers = LoginIdentifiers.Clone(), + UserProfile = UserProfile.Clone(), Endpoints = Endpoints.Clone(), Navigation = Navigation.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs new file mode 100644 index 00000000..0a6ab660 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthUserProfileOptions.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public class UAuthUserProfileOptions +{ + public bool EnableMultiProfile { get; set; } = false; + + internal UAuthUserProfileOptions Clone() => new() + { + EnableMultiProfile = EnableMultiProfile + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs index 7569b9e7..7d0ced82 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs @@ -25,6 +25,10 @@ public AuthContext Create(DateTimeOffset? at = null) var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; + DeviceContext device = result?.BoundDeviceId is { } deviceId + ? DeviceContext.Create(DeviceId.Create(deviceId.Value)) + : DeviceContext.Anonymous(); + if (result is null || !result.IsValid) { return new AuthContext @@ -33,7 +37,7 @@ public AuthContext Create(DateTimeOffset? at = null) Operation = AuthOperation.ResourceAccess, Mode = UAuthMode.PureOpaque, ClientProfile = UAuthClientProfile.Api, - Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + Device = device, At = at ?? _clock.UtcNow, Session = null }; @@ -43,15 +47,15 @@ public AuthContext Create(DateTimeOffset? at = null) { Tenant = result.Tenant, Operation = AuthOperation.ResourceAccess, - Mode = UAuthMode.PureOpaque, // sonra resolver yapılabilir + Mode = UAuthMode.PureOpaque, // TODO: Think about resolver. ClientProfile = UAuthClientProfile.Api, - Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + Device = device, At = at ?? _clock.UtcNow, Session = new SessionSecurityContext { UserKey = result.UserKey, - SessionId = result.SessionId.Value, + SessionId = result.SessionId!.Value, State = result.State, ChainId = result.ChainId, BoundDeviceId = result.BoundDeviceId diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index bc66f3cb..63662572 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -58,8 +58,8 @@ public async Task SendFormAsync(string endpoint, IDictiona if (result.Status == 0) throw new UAuthTransportException("Network error."); - if (result.Status >= 500) - throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); + //if (result.Status >= 500) + // throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); return result; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index 3034f685..8f941e55 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -14,9 +14,13 @@ public interface IUserClient Task DeleteMeAsync(); Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(GetProfileRequest? request = null); Task UpdateMeAsync(UpdateProfileRequest request); + Task CreateMyProfileAsync(CreateProfileRequest request); + Task DeleteMyProfileAsync(ProfileKey profileKey); - Task> GetUserAsync(UserKey userKey); + Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null); Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request); + Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request); + Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 988c0ab2..cf9bb88e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -330,7 +330,7 @@ public async Task CompletePkceLoginAsync(PkceCompleteRequest request) { ["authorization_code"] = request.AuthorizationCode, ["code_verifier"] = request.CodeVerifier, - ["return_url"] = request.ReturnUrl, + ["return_url"] = request.ReturnUrl ?? string.Empty, ["Identifier"] = request.Identifier ?? string.Empty, ["Secret"] = request.Secret ?? string.Empty, diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 001afe2e..14c67252 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -23,15 +23,16 @@ public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, I private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task> GetMeAsync() + public async Task> GetMeAsync(GetProfileRequest? request = null) { - var raw = await _request.SendFormAsync(Url("/me/get")); + request ??= new GetProfileRequest(); + var raw = await _request.SendJsonAsync(Url("/me/profile/get"), request); return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) { - var raw = await _request.SendJsonAsync(Url("/me/update"), request); + var raw = await _request.SendJsonAsync(Url("/me/profile/update"), request); if (raw.Ok) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); @@ -56,12 +57,69 @@ public async Task>> QueryAsync(UserQuery qu return UAuthResultMapper.FromJson>(raw); } + public async Task CreateMyProfileAsync(CreateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/profile/create"), request); + + if (raw.Ok) + { + await _events.PublishAsync( + new UAuthStateEventArgs( + UAuthStateEvent.ProfileChanged, + _options.StateEvents.HandlingMode, + request)); + } + + return UAuthResultMapper.From(raw); + } + + public async Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/create"), request); + return UAuthResultMapper.From(raw); + } + public async Task> CreateAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/users/create"), request); return UAuthResultMapper.FromJson(raw); } + public async Task DeleteMyProfileAsync(ProfileKey profileKey) + { + var request = new DeleteProfileRequest + { + ProfileKey = profileKey + }; + + var raw = await _request.SendJsonAsync(Url("/me/profile/delete"), request); + + if (raw.Ok) + { + await _events.PublishAsync( + new UAuthStateEventArgs( + UAuthStateEvent.ProfileChanged, + _options.StateEvents.HandlingMode, + profileKey)); + } + + return UAuthResultMapper.From(raw); + } + + public async Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey) + { + var request = new DeleteProfileRequest + { + ProfileKey = profileKey + }; + + var raw = await _request.SendJsonAsync( + Url($"/admin/users/{userKey.Value}/profile/delete"), + request); + + return UAuthResultMapper.From(raw); + } + public async Task> CreateAsAdminAsync(CreateUserRequest request) { var raw = await _request.SendJsonAsync(Url("/admin/users/create"), request); @@ -90,9 +148,10 @@ public async Task> DeleteUserAsync(UserKey userKey return UAuthResultMapper.FromJson(raw); } - public async Task> GetUserAsync(UserKey userKey) + public async Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/profile/get")); + request = request ?? new GetProfileRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/get"), request); return UAuthResultMapper.FromJson(raw); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs new file mode 100644 index 00000000..180a1172 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKey.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +[JsonConverter(typeof(ProfileKeyJsonConverter))] +public readonly record struct ProfileKey : IParsable +{ + public string Value { get; } + + private ProfileKey(string value) + { + Value = value; + } + + public static ProfileKey Default => new("default"); + + public static bool TryCreate(string? raw, out ProfileKey key) + { + if (IsValid(raw)) + { + key = new ProfileKey(Normalize(raw!)); + return true; + } + + key = default; + return false; + } + + public static ProfileKey Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var key)) + return key; + + throw new FormatException("Invalid ProfileKey."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out ProfileKey result) + { + if (IsValid(s)) + { + result = new ProfileKey(Normalize(s!)); + return true; + } + + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length > 64) + return false; + + return true; + } + + private static string Normalize(string value) + => value.Trim().ToLowerInvariant(); + + public override string ToString() => Value; + + public static implicit operator string(ProfileKey key) => key.Value; +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs new file mode 100644 index 00000000..31976379 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Domain/ProfileKeyJsonConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class ProfileKeyJsonConverter : JsonConverter +{ + public override ProfileKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("ProfileKey must be a string."); + + var value = reader.GetString(); + + if (!ProfileKey.TryCreate(value, out var key)) + throw new JsonException($"Invalid ProfileKey value: '{value}'"); + + return key; + } + + public override void Write(Utf8JsonWriter writer, ProfileKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs index 43e1fcf8..596f089d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -7,4 +7,5 @@ public sealed record UserQuery : PageRequest public string? Search { get; set; } public UserStatus? Status { get; set; } public bool IncludeDeleted { get; set; } + public ProfileKey? ProfileKey { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs index b245402d..7db9293b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs @@ -12,6 +12,7 @@ public sealed record UserView public string? PrimaryEmail { get; init; } public string? PrimaryPhone { get; init; } + public ProfileKey ProfileKey { get; set; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs new file mode 100644 index 00000000..b1ed0059 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateProfileRequest.cs @@ -0,0 +1,22 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class CreateProfileRequest +{ + public required ProfileKey ProfileKey { get; init; } + + public ProfileKey? CloneFrom { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + + public Dictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs new file mode 100644 index 00000000..ca822e34 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteProfileRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class DeleteProfileRequest +{ + public required ProfileKey ProfileKey { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs new file mode 100644 index 00000000..5d730eab --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/GetProfileRequest.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class GetProfileRequest +{ + public ProfileKey? ProfileKey { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs index 018aac82..73093aa2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -2,6 +2,7 @@ public sealed record UpdateProfileRequest { + public ProfileKey? ProfileKey { get; set; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs index edbf278c..d5d6763d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs @@ -1,6 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -108,6 +110,11 @@ private static void ConfigureProfiles(ModelBuilder b) .HasMaxLength(128) .IsRequired(); + e.Property(x => x.ProfileKey) + .HasConversion(v => v.Value, v => ProfileKey.Parse(v, null)) + .HasMaxLength(64) + .IsRequired(); + e.Property(x => x.Metadata) .HasConversion(new NullableJsonValueConverter>()) .Metadata.SetValueComparer(JsonValueComparers.Create>()); @@ -116,7 +123,7 @@ private static void ConfigureProfiles(ModelBuilder b) e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); - e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.ProfileKey }).IsUnique(); }); } } \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs index aaa2addb..8063234a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -10,6 +10,7 @@ public static UserProfile ToDomain(this UserProfileProjection p) p.Id, p.Tenant, p.UserKey, + p.ProfileKey, p.FirstName, p.LastName, p.DisplayName, @@ -33,6 +34,7 @@ public static UserProfileProjection ToProjection(this UserProfile d) Id = d.Id, Tenant = d.Tenant, UserKey = d.UserKey, + ProfileKey = d.ProfileKey, FirstName = d.FirstName, LastName = d.LastName, DisplayName = d.DisplayName, @@ -66,7 +68,7 @@ public static void UpdateProjection(this UserProfile source, UserProfileProjecti target.Culture = source.Culture; // Version store-owned - // Id / Tenant / UserKey / CreatedAt immutable + // Id / Tenant / UserKey / ProfileKey / CreatedAt immutable } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs index 90dfed20..0698c205 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -11,6 +12,8 @@ public sealed class UserProfileProjection public UserKey UserKey { get; set; } = default!; + public ProfileKey ProfileKey { get; set; } = ProfileKey.Default; + public string? FirstName { get; set; } public string? LastName { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs index 9623dc92..963d4dec 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -2,8 +2,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -28,7 +30,8 @@ public EfCoreUserProfileStore(TDbContext db, TenantContext tenant) .AsNoTracking() .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); return projection?.ToDomain(); @@ -41,7 +44,8 @@ public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = d return await DbSet .AnyAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); } @@ -54,6 +58,16 @@ public async Task AddAsync(UserProfile entity, CancellationToken ct = default) if (entity.Version != 0) throw new InvalidOperationException("New profile must have version 0."); + var exists = await DbSet + .AnyAsync(x => + x.Tenant == entity.Tenant.Value && + x.UserKey == entity.UserKey.Value && + x.ProfileKey == entity.ProfileKey.Value, + ct); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + DbSet.Add(projection); await _db.SaveChangesAsync(ct); @@ -66,7 +80,8 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == entity.UserKey, + x.UserKey == entity.UserKey && + x.ProfileKey == entity.ProfileKey.Value, ct); if (existing is null) @@ -88,7 +103,8 @@ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMo var projection = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && - x.UserKey == key.UserKey, + x.UserKey == key.UserKey && + x.ProfileKey == key.ProfileKey.Value, ct); if (projection is null) @@ -120,6 +136,11 @@ public async Task> QueryAsync(UserProfileQuery query, C .AsNoTracking() .Where(x => x.Tenant == _tenant); + if (query.ProfileKey != null) + { + baseQuery = baseQuery.Where(x => x.ProfileKey == query.ProfileKey.Value); + } + if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => x.DeletedAt == null); @@ -164,7 +185,7 @@ public async Task> QueryAsync(UserProfileQuery query, C query.Descending); } - public async Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + public async Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -172,9 +193,22 @@ public async Task> GetByUsersAsync(IReadOnlyList x.Tenant == _tenant) .Where(x => userKeys.Contains(x.UserKey)) + .Where(x => x.ProfileKey == profileKey.Value) .Where(x => x.DeletedAt == null) .ToListAsync(ct); return projections.Select(x => x.ToDomain()).ToList(); } + + public async Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == _tenant) + .Where(x => x.UserKey == userKey) + .ToListAsync(ct); + return projections.Select(x => x.ToDomain()).ToList(); + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 38195576..bb683ffd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,7 +1,9 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; @@ -9,12 +11,23 @@ namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore, IUserProfileStore { protected override UserProfileKey GetKey(UserProfile entity) - => new(entity.Tenant, entity.UserKey); + => new(entity.Tenant, entity.UserKey, entity.ProfileKey); public InMemoryUserProfileStore(TenantContext tenant) : base(tenant) { } + protected override void BeforeAdd(UserProfile entity) + { + var exists = TenantValues() + .Any(x => + x.UserKey == entity.UserKey && + x.ProfileKey == entity.ProfileKey); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + } + public Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -22,6 +35,11 @@ public Task> QueryAsync(UserProfileQuery query, Cancell var normalized = query.Normalize(); var baseQuery = TenantValues().AsQueryable(); + if (query.ProfileKey != null) + { + baseQuery = baseQuery.Where(x => x.ProfileKey == query.ProfileKey); + } + if (!query.IncludeDeleted) baseQuery = baseQuery.Where(x => !x.IsDeleted); @@ -69,19 +87,36 @@ public Task> QueryAsync(UserProfileQuery query, Cancell query.Descending)); } - public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + public Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var set = userKeys.ToHashSet(); - var result = TenantValues() + var query = TenantValues() .Where(x => set.Contains(x.UserKey)) - .Where(x => !x.IsDeleted) + .Where(x => x.ProfileKey == profileKey) + .Where(x => !x.IsDeleted); + + var result = query .Select(x => x.Snapshot()) .ToList() .AsReadOnly(); return Task.FromResult>(result); } + + public Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var query = TenantValues() + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted); + var result = query + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + return Task.FromResult>(result); + } } \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs index 3c3835e6..6e122100 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public sealed record UserProfileQuery : PageRequest { public bool IncludeDeleted { get; init; } + public ProfileKey? ProfileKey { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 9bc18905..5830a22d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -1,10 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; -// TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) public sealed class UserProfile : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot { private UserProfile() { } @@ -13,6 +13,7 @@ private UserProfile() { } public TenantKey Tenant { get; private set; } public UserKey UserKey { get; init; } = default!; + public ProfileKey ProfileKey { get; set; } = ProfileKey.Default; public string? FirstName { get; private set; } public string? LastName { get; private set; } @@ -44,6 +45,7 @@ public UserProfile Snapshot() Id = Id, Tenant = Tenant, UserKey = UserKey, + ProfileKey = ProfileKey, FirstName = FirstName, LastName = LastName, DisplayName = DisplayName, @@ -65,6 +67,7 @@ public static UserProfile Create( Guid? id, TenantKey tenant, UserKey userKey, + ProfileKey? profileKey, DateTimeOffset createdAt, string? firstName = null, string? lastName = null, @@ -81,6 +84,7 @@ public static UserProfile Create( Id = id ?? Guid.NewGuid(), Tenant = tenant, UserKey = userKey, + ProfileKey = profileKey ?? ProfileKey.Default, FirstName = firstName, LastName = lastName, DisplayName = displayName, @@ -169,6 +173,7 @@ public static UserProfile FromProjection( Guid id, TenantKey tenant, UserKey userKey, + ProfileKey profileKey, string? firstName, string? lastName, string? displayName, @@ -189,6 +194,7 @@ public static UserProfile FromProjection( Id = id, Tenant = tenant, UserKey = userKey, + ProfileKey = profileKey, FirstName = firstName, LastName = lastName, DisplayName = displayName, @@ -205,4 +211,47 @@ public static UserProfile FromProjection( Version = version }; } + + public UserProfile CloneTo( + Guid? newId, + ProfileKey newProfileKey, + DateTimeOffset now, + Action? mutate = null) + { + if (IsDeleted) + throw new InvalidOperationException("cannot_clone_deleted_profile"); + + var clone = new UserProfile + { + Id = newId ?? Guid.NewGuid(), + Tenant = Tenant, + UserKey = UserKey, + ProfileKey = newProfileKey, + + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + + Metadata = Metadata is null + ? null + : new Dictionary(Metadata), + + CreatedAt = now, + UpdatedAt = null, + DeletedAt = null, + Version = 0 + }; + + mutate?.Invoke(clone); + + return clone; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs index c197d94f..2aa643b6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public readonly record struct UserProfileKey( TenantKey Tenant, - UserKey UserKey); + UserKey UserKey, + ProfileKey ProfileKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index b48a4ecf..7ec8f92d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -120,13 +120,15 @@ public async Task GetMeAsync(HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserProfiles.GetSelf, resource: "users", resourceId: flow?.UserKey?.Value); - var profile = await _users.GetMeAsync(accessContext, ctx.RequestAborted); + var profile = await _users.GetMeAsync(accessContext, request?.ProfileKey, ctx.RequestAborted); return Results.Ok(profile); } @@ -136,13 +138,15 @@ public async Task GetUserAsync(UserKey userKey, HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( authFlow: flow, action: UAuthActions.UserProfiles.GetAdmin, resource: "users", resourceId: userKey.Value); - var profile = await _users.GetUserProfileAsync(accessContext, ctx.RequestAborted); + var profile = await _users.GetUserProfileAsync(accessContext, request?.ProfileKey, ctx.RequestAborted); return Results.Ok(profile); } @@ -220,6 +224,78 @@ public async Task DeleteAsync(UserKey userKey, HttpContext ctx) return Results.Ok(); } + public async Task CreateProfileSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.CreateSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.CreateProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task CreateProfileAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.CreateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.CreateProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteProfileSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.DeleteSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.DeleteProfileAsync(accessContext, request.ProfileKey, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteProfileAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.DeleteAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.DeleteProfileAsync(accessContext, request.ProfileKey, ctx.RequestAborted); + return Results.Ok(); + } + public async Task GetMyIdentifiersAsync(HttpContext ctx) { var flow = _authFlow.Current; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs index 72cbe134..f924f3aa 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -13,10 +13,10 @@ public UserProfileSnapshotProvider(IUserProfileStoreFactory storeFactory) _storeFactory = storeFactory; } - public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + public async Task GetAsync(TenantKey tenant, UserKey userKey, ProfileKey profileKey, CancellationToken ct = default) { var store = _storeFactory.Create(tenant); - var profile = await store.GetAsync(new UserProfileKey(tenant, userKey), ct); + var profile = await store.GetAsync(new UserProfileKey(tenant, userKey, profileKey), ct); if (profile is null || profile.IsDeleted) return null; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index e7a95f69..6f3cdf43 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -8,6 +8,7 @@ public static UserView ToDto(UserProfile profile) => new() { UserKey = profile.UserKey, + ProfileKey = profile.ProfileKey, FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 5c9157f1..035eead1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -5,15 +5,17 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserApplicationService { - Task GetMeAsync(AccessContext context, CancellationToken ct = default); - Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + Task GetMeAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default); Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default); Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + Task DeleteProfileAsync(AccessContext context, ProfileKey profileKey, CancellationToken ct = default); Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index aa354974..27ee484a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -75,6 +75,7 @@ await profileStore.AddAsync( Guid.NewGuid(), context.ResourceTenant, userKey, + ProfileKey.Default, now, firstName: request.FirstName, lastName: request.LastName, @@ -195,15 +196,16 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profileKey = new UserProfileKey(context.ResourceTenant, userKey); - var profile = await profileStore.GetAsync(profileKey, innerCt); await lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); await identifierStore.DeleteByUserAsync(userKey, DeleteMode.Soft, now, innerCt); - if (profile is not null) + var profiles = await profileStore.GetAllProfilesByUserAsync(userKey, innerCt); + + foreach (var profile in profiles) { - await profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); + var key = new UserProfileKey(context.ResourceTenant, userKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); } foreach (var integration in _integrations) @@ -233,14 +235,15 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profileKey = new UserProfileKey(context.ResourceTenant, targetUserKey); - var profile = await profileStore.GetAsync(profileKey, innerCt); await lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); await identifierStore.DeleteByUserAsync(targetUserKey, request.Mode, now, innerCt); - if (profile is not null) + var profiles = await profileStore.GetAllProfilesByUserAsync(targetUserKey, innerCt); + + foreach (var profile in profiles) { - await profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); } foreach (var integration in _integrations) @@ -257,31 +260,99 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque #region User Profile - public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + public async Task GetMeAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + if (context.ActorUserKey is null) throw new UnauthorizedAccessException(); - return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); + if (!_options.UserProfile.EnableMultiProfile && effectiveProfileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, effectiveProfileKey, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + public async Task GetUserProfileAsync(AccessContext context, ProfileKey? profileKey = null, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + + if (!_options.UserProfile.EnableMultiProfile && effectiveProfileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + var targetUserKey = context.GetTargetUserKey(); - return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, effectiveProfileKey, innerCt); }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var profileKey = request.ProfileKey; + + if (!_options.UserProfile.EnableMultiProfile) + throw new UAuthConflictException("multi_profile_disabled"); + + if (profileKey == ProfileKey.Default) + throw new UAuthConflictException("default_profile_already_exists"); + + var store = _profileStoreFactory.Create(tenant); + + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, profileKey), innerCt); + + if (exists) + throw new UAuthConflictException("profile_already_exists"); + + UserProfile profile; + if (request.CloneFrom is ProfileKey cloneFromKey) + { + var source = await store.GetAsync(new UserProfileKey(tenant, userKey, cloneFromKey), innerCt); + + if (source == null) + throw new UAuthNotFoundException("source_profile_not_found"); + + profile = source.CloneTo(Guid.NewGuid(), profileKey, now); + } + else + { + profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + profileKey, + now, + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture); + } + + await store.AddAsync(profile, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => @@ -290,13 +361,17 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq var userKey = context.GetTargetUserKey(); var now = _clock.UtcNow; - var key = new UserProfileKey(tenant, userKey); + var profileKey = request.ProfileKey ?? ProfileKey.Default; + var key = new UserProfileKey(tenant, userKey, profileKey); var profileStore = _profileStoreFactory.Create(tenant); var profile = await profileStore.GetAsync(key, innerCt); if (profile is null) throw new UAuthNotFoundException(); + if (!_options.UserProfile.EnableMultiProfile && profileKey != ProfileKey.Default) + throw new UAuthConflictException("multi_profile_disabled"); + var expectedVersion = profile.Version; profile @@ -311,6 +386,39 @@ public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileReq await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteProfileAsync(AccessContext context, ProfileKey profileKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + if (!_options.UserProfile.EnableMultiProfile) + throw new UAuthConflictException("multi_profile_disabled"); + + if (profileKey == ProfileKey.Default) + throw new UAuthConflictException("cannot_delete_default_profile"); + + var store = _profileStoreFactory.Create(tenant); + + var key = new UserProfileKey(tenant, userKey, profileKey); + var profile = await store.GetAsync(key, innerCt); + + if (profile is null || profile.IsDeleted) + throw new UAuthNotFoundException("user_profile_not_found"); + + var profiles = await store.GetAllProfilesByUserAsync(userKey, innerCt); + + if (profiles.Count <= 1) + throw new UAuthConflictException("cannot_delete_last_profile"); + + await store.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + #endregion @@ -658,13 +766,15 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde #region Helpers - private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, ProfileKey? profileKey, CancellationToken ct) { + var effectiveProfileKey = profileKey ?? ProfileKey.Default; + var lifecycleStore = _lifecycleStoreFactory.Create(tenant); var identifierStore = _identifierStoreFactory.Create(tenant); var profileStore = _profileStoreFactory.Create(tenant); var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); - var profile = await profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); + var profile = await profileStore.GetAsync(new UserProfileKey(tenant, userKey, effectiveProfileKey), ct); if (lifecycle is null || lifecycle.IsDeleted) throw new UAuthNotFoundException("user_not_found"); @@ -751,6 +861,7 @@ public async Task> QueryUsersAsync(AccessContext contex var command = new AccessCommand>(async innerCt => { query ??= new UserQuery(); + var effectiveProfileKey = query.ProfileKey ?? ProfileKey.Default; var lifecycleQuery = new UserLifecycleQuery { @@ -778,7 +889,7 @@ public async Task> QueryUsersAsync(AccessContext contex var userKeys = lifecycles.Select(x => x.UserKey).ToList(); var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profiles = await profileStore.GetByUsersAsync(userKeys, innerCt); + var profiles = await profileStore.GetByUsersAsync(userKeys, effectiveProfileKey, innerCt); var identifiers = await identifierStore.GetByUsersAsync(userKeys, innerCt); var profileMap = profiles.ToDictionary(x => x.UserKey); var identifierGroups = identifiers.GroupBy(x => x.UserKey).ToDictionary(x => x.Key, x => x.ToList()); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index 5d63cb60..0b6aff75 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,11 +1,13 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore : IVersionedStore { Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default); - Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default); + Task> GetByUsersAsync(IReadOnlyList userKeys, ProfileKey profileKey, CancellationToken ct = default); + Task> GetAllProfilesByUserAsync(UserKey userKey, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs index 38b2f00c..8a1789b7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs @@ -6,5 +6,5 @@ namespace CodeBeam.UltimateAuth.Users; public interface IUserProfileSnapshotProvider { - Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetAsync(TenantKey tenant, UserKey userKey, ProfileKey profileKey, CancellationToken ct = default); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index a06b2b12..5d8a86af 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; using System.Net.Http.Json; @@ -83,14 +84,14 @@ public async Task Authenticated_User_Should_Access_Me_Endpoint() var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); _client.DefaultRequestHeaders.Add("Cookie", cookie); - var response = await _client.PostAsync("/auth/me/get", null); + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = null }); response.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Anonymous_Should_Not_Access_Me() { - var response = await _client.PostAsync("/auth/me/get", null); + var response = await _client.PostAsync("/auth/me/profile/get", null); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } } \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs new file mode 100644 index 00000000..dfd73b27 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RefreshTests : IClassFixture +{ + private readonly HttpClient _client; + + public RefreshTests(AuthServerFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Refresh_PureOpaque_Should_Touch_Session() + { + await LoginAsync("BlazorServer"); + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + } + + [Fact] + public async Task Refresh_PureOpaque_Invalid_Should_Return_Unauthorized() + { + SetClientProfile("BlazorServer"); + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Rotate_Tokens() + { + await LoginAsync("BlazorWasm"); + + var response = await RefreshAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + cookies.Should().NotBeEmpty(); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_On_Reuse() + { + await LoginAsync("BlazorWasm"); + + var first = await RefreshAsync(); + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await RefreshAsync(); + + second.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_Should_Not_Touch_Immediately() + { + await LoginAsync("BlazorServer"); + + var first = await RefreshAsync(); + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await RefreshAsync(); + + second.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_When_Session_Mismatch() + { + var factory = new AuthServerFactory(); + + var client1 = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var client2 = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var cookie1 = await LoginAsync(client1, "BlazorWasm", "device-1-1234567890123456"); + var cookie2 = await LoginAsync(client2, "BlazorWasm", "device-2-1234567890123456"); + + cookie1.Should().NotBeNullOrWhiteSpace(); + cookie2.Should().NotBeNullOrWhiteSpace(); + cookie1.Should().NotBe(cookie2); + + client2.DefaultRequestHeaders.Remove("Cookie"); + client2.DefaultRequestHeaders.Add("Cookie", cookie1); + var response = await client2.PostAsync("/auth/refresh", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_Should_Fail_Without_RefreshToken() + { + await LoginAsync("BlazorWasm"); + var cookies = _client.DefaultRequestHeaders.GetValues("Cookie").First(); + var onlySession = string.Join("; ", cookies.Split("; ").Where(x => x.StartsWith("uas="))); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", onlySession); + + var response = await _client.PostAsync("/auth/refresh", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + + private async Task LoginAsync(string profile) + { + SetClientProfile(profile); + + var response = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookies = response.Headers.GetValues("Set-Cookie") + .Select(x => x.Split(';')[0]); + + var cookieHeader = string.Join("; ", cookies); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + } + + private async Task LoginAsync(HttpClient client, string profile, string udid = "test-device-1234567890123456") + { + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Remove("X-UDID"); + client.DefaultRequestHeaders.Add("X-UDID", udid); + + SetClientProfile(client, profile); + + var response = await client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookieHeader = BuildCookieHeader(response); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + + return cookieHeader; + } + + private void SetClientProfile(string profile) + { + _client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); + _client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + } + + private void SetClientProfile(HttpClient client, string profile) + { + client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + } + + private static string BuildCookieHeader(HttpResponseMessage response) + { + var cookies = response.Headers.GetValues("Set-Cookie") + .Select(x => x.Split(';')[0]); + + return string.Join("; ", cookies); + } + + private Task RefreshAsync() + { + return _client.PostAsync("/auth/refresh", null); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs new file mode 100644 index 00000000..4859ea70 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -0,0 +1,215 @@ +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class UserProfileTests : IClassFixture +{ + private readonly HttpClient _client; + + public UserProfileTests(AuthServerFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Profile_Switch_Should_Return_Correct_Profile_Data() + { + var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var defaultResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + defaultResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var defaultProfile = await defaultResponse.Content.ReadFromJsonAsync(); + defaultProfile.Should().NotBeNull(); + + var createResponse = await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + { + ProfileKey = ProfileKey.Parse("business", null) + }); + + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var updateResponse = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest() + { + ProfileKey = ProfileKey.Parse("business", null), + DisplayName = "Updated Business Name" + }); + + updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var businessResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() + { + ProfileKey = ProfileKey.Parse("business", null) + }); + + businessResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var businessProfile = await businessResponse.Content.ReadFromJsonAsync(); + + businessProfile.Should().NotBeNull(); + businessProfile!.DisplayName.Should().Be("Updated Business Name"); + + var defaultAgainResponse = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + + var defaultAgain = await defaultAgainResponse.Content.ReadFromJsonAsync(); + + defaultAgain!.DisplayName.Should().Be(defaultProfile!.DisplayName); + } + + [Fact] + public async Task GetMe_Without_ProfileKey_Should_Return_Default_Profile() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new { }); + + var profile = await response.Content.ReadFromJsonAsync(); + + profile.Should().NotBeNull(); + profile!.ProfileKey.Value.Should().Be("default"); + } + + [Fact] + public async Task Should_Not_Found_NonDefault_Profile_When_Not_Created() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new + { + profileKey = "business" + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Should_Not_Create_Duplicate_Profile() + { + var login = await Login(); + + var key = ProfileKey.Parse($"business-{Guid.NewGuid()}", null); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var request = new CreateProfileRequest + { + ProfileKey = key + }; + + var first = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await _client.PostAsJsonAsync("/auth/me/profile/create", request); + second.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Should_Not_Delete_Default_Profile() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/delete", new + { + profileKey = "default" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Should_Not_Update_NonExisting_Profile() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/update", new UpdateProfileRequest + { + ProfileKey = ProfileKey.Parse("ghost", null), + DisplayName = "Should Fail" + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Deleted_Profile_Should_Not_Be_Returned() + { + var login = await Login(); + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var key = ProfileKey.Parse("business", null); + + await _client.PostAsJsonAsync("/auth/me/profile/create", new CreateProfileRequest + { + ProfileKey = key + }); + + await _client.PostAsJsonAsync("/auth/me/profile/delete", new + { + profileKey = key.Value + }); + + var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest + { + ProfileKey = key + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + + private async Task Login() + { + var response = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = response.Headers.GetValues("Set-Cookie").First(); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + return response; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs index eb2cbf2c..aa718bf4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs @@ -18,12 +18,15 @@ public async Task GetMe_Should_Call_Correct_Endpoint() UserKey = UserKey.FromString("user-1") }; - Request.Setup(x => x.SendFormAsync(It.IsAny())) + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SuccessJson(response)); var client = CreateUserClient(); await client.Users.GetMeAsync(); - Request.Verify(x => x.SendFormAsync("/auth/me/get"), Times.Once); + + Request.Verify(x => x.SendJsonAsync( + "/auth/me/profile/get", + It.Is(o => o is GetProfileRequest && ((GetProfileRequest)o).ProfileKey == null)), Times.Once); } [Fact] @@ -155,12 +158,15 @@ public async Task GetUser_Should_Call_Admin_Endpoint() UserKey = userKey }; - Request.Setup(x => x.SendFormAsync(It.IsAny())) + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SuccessJson(response)); var client = CreateUserClient(); await client.Users.GetUserAsync(userKey); - Request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/profile/get"), Times.Once); + + Request.Verify(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/profile/get", + It.Is(o => o is GetProfileRequest && ((GetProfileRequest)o).ProfileKey == null)), Times.Once); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs index 87d84078..69b79f4f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Data.Sqlite; @@ -31,6 +32,7 @@ public async Task Add_And_Get_Should_Work() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -38,7 +40,7 @@ public async Task Add_And_Get_Should_Work() ); await store.AddAsync(profile); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.NotNull(result); Assert.Equal(userKey, result!.UserKey); @@ -59,6 +61,7 @@ public async Task Exists_Should_Return_True_When_Exists() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -66,7 +69,7 @@ public async Task Exists_Should_Return_True_When_Exists() ); await store.AddAsync(profile); - var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey)); + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.True(exists); } @@ -83,6 +86,7 @@ public async Task Save_Should_Increment_Version() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -98,7 +102,7 @@ public async Task Save_Should_Increment_Version() await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await store2.SaveAsync(updated, expectedVersion: 0); } @@ -106,7 +110,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); - var result = await store3.GetAsync(new UserProfileKey(tenant, userKey)); + var result = await store3.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.Equal(1, result!.Version); Assert.Equal("new", result.DisplayName); @@ -125,6 +129,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -140,7 +145,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db2 = CreateDb(connection)) { var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); - var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await Assert.ThrowsAsync(() => @@ -166,6 +171,7 @@ public async Task Should_Not_See_Data_From_Other_Tenant() Guid.NewGuid(), tenant1, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -173,7 +179,7 @@ public async Task Should_Not_See_Data_From_Other_Tenant() ); await store1.AddAsync(profile); - var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey)); + var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey, ProfileKey.Default)); Assert.Null(result); } @@ -193,6 +199,7 @@ public async Task Soft_Delete_Should_Work() Guid.NewGuid(), tenant, userKey, + ProfileKey.Default, DateTimeOffset.UtcNow, displayName: "display", firstName: "first", @@ -202,14 +209,195 @@ public async Task Soft_Delete_Should_Work() await store.AddAsync(profile); await store.DeleteAsync( - new UserProfileKey(tenant, userKey), + new UserProfileKey(tenant, userKey, ProfileKey.Default), expectedVersion: 0, DeleteMode.Soft, DateTimeOffset.UtcNow); - var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.NotNull(result); Assert.NotNull(result!.DeletedAt); } + + [Fact] + public async Task Same_User_Can_Have_Multiple_Profiles() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var defaultProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default"); + + var businessProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business"); + + await store.AddAsync(defaultProfile); + await store.AddAsync(businessProfile); + + var p1 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); + var p2 = await store.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.NotNull(p1); + Assert.NotNull(p2); + Assert.NotEqual(p1!.ProfileKey, p2!.ProfileKey); + } + + [Fact] + public async Task GetAsync_Should_Return_Correct_Profile_By_ProfileKey() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default")); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business")); + + var result = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.Equal("business", result!.DisplayName); + } + + [Fact] + public async Task GetByUsersAsync_Should_Filter_By_ProfileKey() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow, + displayName: "default")); + + await store.AddAsync(UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow, + displayName: "business")); + + var results = await store.GetByUsersAsync( + new[] { userKey }, + ProfileKey.Default); + + Assert.Single(results); + Assert.Equal(ProfileKey.Default, results[0].ProfileKey); + } + + [Fact] + public async Task Should_Not_Allow_Duplicate_ProfileKey_For_Same_User() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile1 = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + var profile2 = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + await store.AddAsync(profile1); + + await Assert.ThrowsAsync(() => + store.AddAsync(profile2)); + } + + [Fact] + public async Task Delete_Should_Not_Affect_Other_Profiles() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var defaultProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Default, + DateTimeOffset.UtcNow); + + var businessProfile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + ProfileKey.Parse("business", null), + DateTimeOffset.UtcNow); + + await store.AddAsync(defaultProfile); + await store.AddAsync(businessProfile); + + await store.DeleteAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Default), + 0, + DeleteMode.Soft, + DateTimeOffset.UtcNow); + + var defaultResult = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Default)); + + var businessResult = await store.GetAsync( + new UserProfileKey(tenant, userKey, ProfileKey.Parse("business", null))); + + Assert.NotNull(defaultResult!.DeletedAt); + Assert.Null(businessResult!.DeletedAt); + } }