diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.Designer.cs new file mode 100644 index 00000000..0708b188 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.Designer.cs @@ -0,0 +1,780 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261111192531_FixAuthenticationSecurityStateUniqueness")] + partial class FixAuthenticationSecurityStateUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType"), false); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialId") + .HasColumnType("uuid"); + + b.Property("ReplacedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + 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/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.cs new file mode 100644 index 00000000..99fcee52 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261111192531_FixAuthenticationSecurityStateUniqueness.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + /// + public partial class FixAuthenticationSecurityStateUniqueness : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication"); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope", "CredentialType" }, + unique: true) + .Annotation("Npgsql:NullsDistinct", false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication"); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope", "CredentialType" }, + unique: true); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs index 55d8efe3..2e3c58e4 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.14") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -89,6 +89,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") .IsUnique(); + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType"), false); + b.ToTable("UAuth_Authentication", (string)null); }); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.Designer.cs new file mode 100644 index 00000000..1ca04a07 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.Designer.cs @@ -0,0 +1,766 @@ +// +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.UAuthHub.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261111192825_FixAuthenticationSecurityStateUniqueness")] + partial class FixAuthenticationSecurityStateUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + 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() + .HasAnnotation("Npgsql:NullsDistinct", false); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .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") + .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", "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/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.cs new file mode 100644 index 00000000..b84b884e --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261111192825_FixAuthenticationSecurityStateUniqueness.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations +{ + /// + public partial class FixAuthenticationSecurityStateUniqueness : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index ceee4996..281591dd 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class UAuthDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -82,7 +82,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "Scope"); b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") - .IsUnique(); + .IsUnique() + .HasAnnotation("Npgsql:NullsDistinct", false); b.ToTable("UAuth_Authentication", (string)null); }); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db index c9cf4b4e..6bcf8d7a 100644 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm deleted file mode 100644 index 82b7d9dd..00000000 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm and /dev/null differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal deleted file mode 100644 index f799821e..00000000 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal and /dev/null differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261111192647_FixAuthenticationSecurityStateUniqueness.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261111192647_FixAuthenticationSecurityStateUniqueness.Designer.cs new file mode 100644 index 00000000..3a5227a0 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261111192647_FixAuthenticationSecurityStateUniqueness.Designer.cs @@ -0,0 +1,766 @@ +// +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("20261111192647_FixAuthenticationSecurityStateUniqueness")] + partial class FixAuthenticationSecurityStateUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + 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() + .HasAnnotation("Npgsql:NullsDistinct", false); + + 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.Credentials.EntityFrameworkCore.PasswordHistoryProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("ReplacedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "CredentialId", "ReplacedAt", "Id"); + + b.ToTable("UAuth_PasswordHistory", (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"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + 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", "DeletedAt", "CreatedAt", "Id"); + + 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.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(128) + .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") + .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", "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/20261111192647_FixAuthenticationSecurityStateUniqueness.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261111192647_FixAuthenticationSecurityStateUniqueness.cs new file mode 100644 index 00000000..483767d9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261111192647_FixAuthenticationSecurityStateUniqueness.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class FixAuthenticationSecurityStateUniqueness : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} 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 8b10c53a..e8eee716 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 @@ -15,7 +15,7 @@ partial class UAuthDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -82,7 +82,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "Scope"); b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") - .IsUnique(); + .IsUnique() + .HasAnnotation("Npgsql:NullsDistinct", false); b.ToTable("UAuth_Authentication", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db index c9cf4b4e..2be20ba2 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db differ 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 deleted file mode 100644 index 38ab800e..00000000 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and /dev/null 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 deleted file mode 100644 index c3298258..00000000 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and /dev/null differ diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs index 4677358e..cfcbb601 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -21,8 +21,13 @@ public sealed class UAuthLoginOptions /// /// Gets or sets a value indicating whether detailed information about authentication failures (like locked until or /// remaining attempts) is included in responses. + /// Disabled by default to reduce account enumeration risks. /// - public bool IncludeFailureDetails { get; set; } = true; + /// + /// Enabling this option may reveal account existence or lockout state + /// to unauthenticated clients. Use only when this disclosure is acceptable. + /// + public bool IncludeFailureDetails { get; set; } /// /// Gets or sets the time interval during which failed login attempts are counted for lockout purposes. diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 425e450b..89cfb72b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -91,14 +91,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (accountState.IsLocked(now)) { - return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); + return CreateFailure(flow, AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.CredentialType, ct); if (factorState.IsLocked(now)) { - return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); + return CreateFailure(flow, AuthFailureReason.LockedOut, factorState.LockedUntil, remainingAttempts: 0); } foreach (var provider in _credentialProviders) @@ -193,16 +193,16 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } } - - return LoginResult.Failed( + return CreateFailure( + flow, factorState.IsLocked(now) ? AuthFailureReason.LockedOut - : decision.FailureReason, + : decision.FailureReason ?? AuthFailureReason.InvalidCredentials, lockedUntil, remainingAttempts); } - return LoginResult.Failed(decision.FailureReason); + return CreateFailure(flow, decision.FailureReason ?? AuthFailureReason.InvalidCredentials); } @@ -215,7 +215,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req } if (!credentialsValid || userKey is null) - return LoginResult.Failed(AuthFailureReason.InvalidCredentials); + return CreateFailure(flow, AuthFailureReason.InvalidCredentials); // After this point, the login is successful. We can reset any failure counts and proceed to create a session. if (loginExecution.Mode == LoginExecutionMode.Preview) @@ -279,4 +279,12 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Success(issuedSession.Session.SessionId, tokens); } + + private static LoginResult CreateFailure(AuthFlowContext flow, AuthFailureReason reason, DateTimeOffset? lockedUntil = null, int? remainingAttempts = null) + { + if (!flow.Options.Login.IncludeFailureDetails) + return LoginResult.Failed(AuthFailureReason.InvalidCredentials); + + return LoginResult.Failed(reason, lockedUntil, remainingAttempts); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs index efbaa8f7..1c0f5c91 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -68,12 +68,22 @@ private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? // Failure payload if (failureReason is not null) { + var publicReason = failureReason; + + if (flow.FlowType == AuthFlowType.Login && + !flow.Options.Login.IncludeFailureDetails && + loginResult is not null && + !loginResult.IsSuccess) + { + publicReason = AuthFailureReason.InvalidCredentials; + } + var payload = new AuthFlowPayload { V = 1, Flow = flow.FlowType, Status = "failed", - Reason = failureReason + Reason = publicReason }; if (flow.FlowType == AuthFlowType.Login && loginResult is not null && flow.Options.Login.IncludeFailureDetails) diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs index 4689db06..606fb6e4 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs @@ -65,7 +65,7 @@ private static void ConfigureAuthenticationSecurityState(ModelBuilder b) e.Property(x => x.ResetAttempts) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.UserKey, x.Scope, x.CredentialType }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Scope, x.CredentialType }).IsUnique().HasAnnotation("Npgsql:NullsDistinct", false); e.HasIndex(x => new { x.Tenant, x.UserKey }); e.HasIndex(x => new { x.Tenant, x.LockedUntil }); e.HasIndex(x => new { x.Tenant, x.ResetRequestedAt }); diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs index 98f4ee94..b60d9529 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; using Microsoft.EntityFrameworkCore; +using System.Data.Common; namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; @@ -57,7 +58,15 @@ public async Task AddAsync(AuthenticationSecurityState state, CancellationToken DbSet.Add(entity); - await _db.SaveChangesAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (IsSecurityStateUniqueViolation(ex)) + { + DetachEntries(ex); + throw new UAuthConflictException("security_state_already_exists"); + } } public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) @@ -81,7 +90,16 @@ public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVe AuthenticationSecurityStateMapper.UpdateProjection(state, entity); - await _db.SaveChangesAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException ex) + { + DetachEntries(ex); + + throw new UAuthConflictException("security_state_version_conflict"); + } } public async Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) @@ -101,4 +119,33 @@ public async Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope await _db.SaveChangesAsync(ct); } + + private static void DetachEntries(DbUpdateException exception) + { + foreach (var entry in exception.Entries) + { + entry.State = EntityState.Detached; + } + } + + private static bool IsSecurityStateUniqueViolation(DbUpdateException exception) + { + if (exception.Entries.Count != 1 || exception.Entries[0].Entity is not AuthenticationSecurityStateProjection) + { + return false; + } + + for (Exception? current = exception.InnerException; + current is not null; + current = current.InnerException) + { + if (current is DbException dbException && + dbException.SqlState == "23505") + { + return true; + } + } + + return false; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/AuthenticationSecurityPostgresTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/AuthenticationSecurityPostgresTests.cs new file mode 100644 index 00000000..2968fa97 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/AuthenticationSecurityPostgresTests.cs @@ -0,0 +1,200 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore.Authentication; + +public sealed class AuthenticationSecurityPostgresTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public async Task Account_state_must_be_unique_when_credential_type_is_null() + { + await using var database = await postgres.CreateDatabaseAsync(); + + var options = + new DbContextOptionsBuilder() + .UseNpgsql(database.ConnectionString) + .Options; + + await using (var db = new UAuthAuthenticationDbContext(options)) + { + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + } + + await using var connection = + new NpgsqlConnection(database.ConnectionString); + + await connection.OpenAsync(TestContext.Current.CancellationToken); + + const string insert = """ + INSERT INTO "UAuth_Authentication" + ("Id", "Tenant", "UserKey", "Scope", + "CredentialType", "FailedAttempts", + "RequiresReauthentication", "ResetAttempts", + "SecurityVersion") + VALUES + (@id, 'default', 'test-user', 0, + NULL, 0, FALSE, 0, 0); + """; + + async Task InsertAsync() + { + await using var command = new NpgsqlCommand(insert, connection); + command.Parameters.AddWithValue("id", Guid.NewGuid()); + await command.ExecuteNonQueryAsync(); + } + + await InsertAsync(); + + var exception = await Assert.ThrowsAsync( + InsertAsync); + + Assert.Equal(PostgresErrorCodes.UniqueViolation, exception.SqlState); + } + + [Fact] + public async Task Concurrent_account_creation_should_persist_one_state() + { + await using var database = await postgres.CreateDatabaseAsync(); + await using var provider = await CreateProviderAsync( + database.ConnectionString); + + var tenant = TenantKey.FromExternal("concurrency-test"); + var user = UserKey.FromString("concurrent-user"); + + await using var scope1 = provider.CreateAsyncScope(); + await using var scope2 = provider.CreateAsyncScope(); + + var store1 = CreateStore(scope1.ServiceProvider, tenant); + var store2 = CreateStore(scope2.ServiceProvider, tenant); + + var ct = TestContext.Current.CancellationToken; + + // Both requests observe the state before creation. + Assert.Null(await store1.GetAsync( + user, AuthenticationSecurityScope.Account, null, ct)); + + Assert.Null(await store2.GetAsync( + user, AuthenticationSecurityScope.Account, null, ct)); + + var state1 = AuthenticationSecurityState.CreateAccount(tenant, user); + var state2 = AuthenticationSecurityState.CreateAccount(tenant, user); + + var outcomes = await Task.WhenAll( + AttemptAddAsync(store1, state1, ct), + AttemptAddAsync(store2, state2, ct)); + + Assert.Equal(1, outcomes.Count(x => x)); + Assert.Equal(1, outcomes.Count(x => !x)); + + await using var verification = provider.CreateAsyncScope(); + var stored = await CreateStore( + verification.ServiceProvider, tenant) + .GetAsync(user, AuthenticationSecurityScope.Account, null, ct); + + Assert.NotNull(stored); + } + + [Fact] + public async Task Stale_security_version_should_be_rejected() + { + await using var database = await postgres.CreateDatabaseAsync(); + await using var provider = await CreateProviderAsync( + database.ConnectionString); + + var tenant = TenantKey.FromExternal("version-test"); + var user = UserKey.FromString("version-user"); + var ct = TestContext.Current.CancellationToken; + + await using var scope1 = provider.CreateAsyncScope(); + await using var scope2 = provider.CreateAsyncScope(); + + var store1 = CreateStore(scope1.ServiceProvider, tenant); + var store2 = CreateStore(scope2.ServiceProvider, tenant); + + await store1.AddAsync( + AuthenticationSecurityState.CreateAccount(tenant, user), ct); + + var original1 = await store1.GetAsync( + user, AuthenticationSecurityScope.Account, null, ct); + + var original2 = await store2.GetAsync( + user, AuthenticationSecurityScope.Account, null, ct); + + Assert.NotNull(original1); + Assert.NotNull(original2); + + var now = DateTimeOffset.UtcNow; + + var first = original1!.RegisterFailure( + now, 10, TimeSpan.FromMinutes(15), + TimeSpan.FromMinutes(15)); + + await store1.UpdateAsync( + first, original1.SecurityVersion, ct); + + var second = original2!.RegisterFailure( + now, 10, TimeSpan.FromMinutes(15), + TimeSpan.FromMinutes(15)); + + await Assert.ThrowsAsync( + () => store2.UpdateAsync( + second, original2.SecurityVersion, ct)); + + await using var verification = provider.CreateAsyncScope(); + + var persisted = await CreateStore( + verification.ServiceProvider, tenant) + .GetAsync(user, AuthenticationSecurityScope.Account, null, ct); + + Assert.NotNull(persisted); + Assert.Equal(1, persisted!.FailedAttempts); + Assert.Equal(first.SecurityVersion, persisted.SecurityVersion); + } + + private static async Task AttemptAddAsync( + IAuthenticationSecurityStateStore store, + AuthenticationSecurityState state, + CancellationToken ct) + { + try + { + await store.AddAsync(state, ct); + return true; + } + catch (UAuthConflictException) + { + return false; + } + } + + + private static async Task CreateProviderAsync(string connectionString) + { + var services = new ServiceCollection(); + + services.AddUltimateAuthAuthenticationEntityFrameworkCore(options => options.UseNpgsql(connectionString)); + + var provider = services.BuildServiceProvider(validateScopes: true); + + await using var scope = provider.CreateAsyncScope(); + + var db = scope.ServiceProvider.GetRequiredService(); + + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + return provider; + } + + private static IAuthenticationSecurityStateStore CreateStore(IServiceProvider services, TenantKey tenant) + { + return services.GetRequiredService().Create(tenant); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj index dc1e7447..b30b0606 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj @@ -11,6 +11,8 @@ + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/PostgresTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/PostgresTestDatabase.cs new file mode 100644 index 00000000..abfa7e34 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/PostgresTestDatabase.cs @@ -0,0 +1,74 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +public sealed class PostgresFixture : IAsyncLifetime +{ + private PostgreSqlContainer? _container; + + public string ConnectionString { get; private set; } = ""; + + public async ValueTask InitializeAsync() + { + ConnectionString = + Environment.GetEnvironmentVariable("UAUTH_TEST_POSTGRES") ?? ""; + + if (!string.IsNullOrWhiteSpace(ConnectionString)) + return; + + _container = new PostgreSqlBuilder("postgres:17-alpine") + .Build(); + + await _container.StartAsync(); + + ConnectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + await _container.DisposeAsync(); + } + + public async Task CreateDatabaseAsync() + { + var name = "uauth_test_" + Guid.NewGuid().ToString("N"); + + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var command = + new NpgsqlCommand($"CREATE DATABASE {name}", connection); + + await command.ExecuteNonQueryAsync(); + + var builder = new NpgsqlConnectionStringBuilder(ConnectionString) + { + Database = name, + Pooling = false + }; + + return new PostgresTestDatabase( + builder.ConnectionString, + ConnectionString, + name); + } +} + +public sealed class PostgresTestDatabase(string connectionString, string adminConnectionString, string name) : IAsyncDisposable +{ + public string ConnectionString => connectionString; + + public async ValueTask DisposeAsync() + { + await using var connection = + new NpgsqlConnection(adminConnectionString); + + await connection.OpenAsync(); + + await using var command = new NpgsqlCommand($"DROP DATABASE {name} WITH (FORCE)", connection); + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestDevice.cs new file mode 100644 index 00000000..b11b81bb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestDevice.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +internal static class TestDevice +{ + public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); + public static DeviceContext Alternative() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-alternative"), null, null, null, null, null); + public static DeviceInfo DefaultDeviceInfo() => new DeviceInfo() { DeviceId = DeviceId.Create("test-device-info-000-000-000-000-01") }; + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginAccountEnumerationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginAccountEnumerationTests.cs new file mode 100644 index 00000000..73810efc --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginAccountEnumerationTests.cs @@ -0,0 +1,356 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Server.Options; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class LoginAccountEnumerationTests +{ + [Fact] + public void IncludeFailureDetails_ShouldBeDisabledByDefault() + { + using var factory = new AuthServerFactory(); + + var options = factory.Services + .GetRequiredService>() + .Value; + + options.Login.IncludeFailureDetails.Should().BeFalse(); + } + + [Fact] + public async Task TryLogin_UnknownUserAndWrongPassword_ShouldHaveSamePublicFailure() + { + using var factory = new AuthServerFactory(); + + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); + + using var client = CreateClient(factory); + + var unknown = await TryLoginAsync( + client, + $"unknown-{Guid.NewGuid():N}", + "wrong-password"); + + var existing = await TryLoginAsync( + client, + user.Identifier, + "wrong-password"); + + AssertGenericFailure(unknown); + AssertGenericFailure(existing); + } + + [Fact] + public async Task TryLogin_NewAndExistingLockout_ShouldNotExposeAccountState() + { + using var factory = new AuthServerFactory(); + + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); + + using var client = CreateClient(factory); + + var first = await TryLoginAsync( + client, user.Identifier, "wrong-password-1"); + + var second = await TryLoginAsync( + client, user.Identifier, "wrong-password-2"); + + // The second failure triggers the configured lockout. + AssertGenericFailure(first); + AssertGenericFailure(second); + + // Even a correct password must not reveal active lockout. + var locked = await TryLoginAsync( + client, user.Identifier, user.Secret); + + AssertGenericFailure(locked); + } + + [Fact] + public async Task Login_FailureRedirect_ShouldNotExposeLockoutInformation() + { + using var factory = new AuthServerFactory(); + + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); + + using var client = CreateClient(factory); + + var first = await LoginAsync( + client, user.Identifier, "wrong-password-1"); + + var second = await LoginAsync( + client, user.Identifier, "wrong-password-2"); + + var locked = await LoginAsync( + client, user.Identifier, user.Secret); + + AssertGenericRedirectFailure(first); + AssertGenericRedirectFailure(second); + AssertGenericRedirectFailure(locked); + } + + [Fact] + public async Task TryLogin_ExplicitDetailedMode_ShouldPreserveFailureDetails() + { + using var factory = AuthServerFactory.Create(options => + { + options.Login.IncludeFailureDetails = true; + }); + + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); + + using var client = CreateClient(factory); + + var first = await TryLoginAsync( + client, user.Identifier, "wrong-password-1"); + + first.IsSuccess.Should().BeFalse(); + first.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + first.RemainingAttempts.Should().Be(1); + first.LockoutUntilUtc.Should().BeNull(); + + var second = await TryLoginAsync( + client, user.Identifier, "wrong-password-2"); + + second.IsSuccess.Should().BeFalse(); + second.Reason.Should().Be(AuthFailureReason.LockedOut); + second.RemainingAttempts.Should().Be(0); + second.LockoutUntilUtc.Should().NotBeNull(); + + var locked = await TryLoginAsync( + client, user.Identifier, user.Secret); + + locked.IsSuccess.Should().BeFalse(); + locked.Reason.Should().Be(AuthFailureReason.LockedOut); + locked.RemainingAttempts.Should().Be(0); + locked.LockoutUntilUtc.Should().Be(second.LockoutUntilUtc); + } + + [Fact] + public async Task PkcePreview_UnknownWrongPasswordAndLockedAccount_ShouldReturnGenericFailure() + { + using var factory = new AuthServerFactory(); + + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); + + using var client = CreateClient(factory); + + var unknown = await TryPkceLoginAsync( + client, + $"unknown-{Guid.NewGuid():N}", + "wrong-password"); + + AssertGenericPkceFailure(unknown); + + var first = await TryPkceLoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + AssertGenericPkceFailure(first); + + // MaxFailedAttempts = 2 in the integration host. + var second = await TryPkceLoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + AssertGenericPkceFailure(second); + + // Correct credentials must not expose an active lockout. + var locked = await TryPkceLoginAsync( + client, + user.Identifier, + user.Secret); + + AssertGenericPkceFailure(locked); + } + + private static void AssertGenericPkceFailure( + TryPkceLoginResult result) + { + result.IsSuccess.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.RemainingAttempts.Should().BeNull(); + result.LockoutUntilUtc.Should().BeNull(); + result.RequiresMfa.Should().BeFalse(); + result.RetryWithNewPkce.Should().BeFalse(); + } + + private static async Task TryPkceLoginAsync( + HttpClient client, + string identifier, + string secret) + { + // Each attempt uses its own valid PKCE authorization. + var verifier = WebEncoders.Base64UrlEncode( + System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + + var challenge = WebEncoders.Base64UrlEncode( + System.Security.Cryptography.SHA256.HashData( + Encoding.ASCII.GetBytes(verifier))); + + using var authorizeResponse = await CsrfTestClient.PostJsonAsync( + client, + "/auth/pkce/authorize", + new + { + codeChallenge = challenge, + challengeMethod = "S256", + device = TestDevice.Default() + }); + + authorizeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var authorization = await authorizeResponse.Content + .ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + + authorization.Should().NotBeNull(); + authorization!.AuthorizationCode.Should().NotBeNullOrWhiteSpace(); + + using var response = await CsrfTestClient.PostJsonAsync( + client, + "/auth/pkce/try-complete", + new + { + authorization_code = authorization.AuthorizationCode, + code_verifier = verifier, + identifier, + secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + + result.Should().NotBeNull(); + + return result!; + } + + + private static void AssertGenericFailure(TryLoginResult result) + { + result.IsSuccess.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.RemainingAttempts.Should().BeNull(); + result.LockoutUntilUtc.Should().BeNull(); + result.PreviewReceipt.Should().BeNullOrWhiteSpace(); + } + + private static void AssertGenericRedirectFailure( + HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + response.Headers.Location.Should().NotBeNull(); + + var location = response.Headers.Location!; + var query = QueryHelpers.ParseQuery(location.Query); + + query.ContainsKey("uauth").Should().BeTrue(); + + var encoded = query["uauth"].ToString(); + + var json = Encoding.UTF8.GetString( + WebEncoders.Base64UrlDecode(encoded)); + + using var document = JsonDocument.Parse(json); + + var root = document.RootElement; + + root.GetProperty("Status") + .GetString() + .Should().Be("failed"); + + root.GetProperty("Reason") + .GetInt32() + .Should().Be((int)AuthFailureReason.InvalidCredentials); + + root.TryGetProperty("RemainingAttempts", out _) + .Should().BeFalse(); + + root.TryGetProperty("LockoutUntil", out _) + .Should().BeFalse(); + } + + private static HttpClient CreateClient(AuthServerFactory factory) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", $"enumeration-{Guid.NewGuid():N}"); + + return client; + } + + private static async Task TryLoginAsync( + HttpClient client, + string identifier, + string secret) + { + using var response = await CsrfTestClient.PostJsonAsync( + client, + "/auth/try-login", + new LoginRequest + { + Identifier = identifier, + Secret = secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + + result.Should().NotBeNull(); + return result!; + } + + private static Task LoginAsync( + HttpClient client, + string identifier, + string secret) + { + return CsrfTestClient.PostJsonAsync( + client, + "/auth/login", + new LoginRequest + { + Identifier = identifier, + Secret = secret + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index 5cdc5fc4..c5415251 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -783,7 +783,9 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc correctResult.Should().NotBeNull(); correctResult!.IsSuccess.Should().BeFalse(); - correctResult.Reason.Should().Be(AuthFailureReason.LockedOut); + correctResult.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + correctResult.RemainingAttempts.Should().BeNull(); + correctResult.LockoutUntilUtc.Should().BeNull(); } [Fact] @@ -1575,11 +1577,14 @@ await LoginAsync( [Fact] public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockout() { - _factory.Clock.Reset(); + using var factory = CreateDetailedFailureFactory(); + factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); using var client = CreateClient( + factory, $"failure-window-inside-{Guid.NewGuid():N}"); var first = await TryLoginAsync( @@ -1592,7 +1597,7 @@ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockou first.RemainingAttempts.Should().Be(1); first.LockoutUntilUtc.Should().BeNull(); - _factory.Clock.Advance(TimeSpan.FromMinutes(14)); + factory.Clock.Advance(TimeSpan.FromMinutes(14)); var second = await TryLoginAsync( client, @@ -1608,11 +1613,14 @@ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockou [Fact] public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLockOnFailureIsDisabled() { - _factory.Clock.Reset(); + using var factory = CreateDetailedFailureFactory(); + factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); using var client = CreateClient( + factory, $"lockout-no-extension-{Guid.NewGuid():N}"); var first = await TryLoginAsync( @@ -1634,7 +1642,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo var originalLockoutUntil = second.LockoutUntilUtc!.Value; - _factory.Clock.Advance(TimeSpan.FromSeconds(5)); + factory.Clock.Advance(TimeSpan.FromSeconds(5)); var duringLockout = await TryLoginAsync( client, @@ -1647,7 +1655,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo duringLockout.LockoutUntilUtc .Should().Be(originalLockoutUntil); - _factory.Clock.Set( + factory.Clock.Set( originalLockoutUntil.AddMilliseconds(1)); var login = await LoginAsync( @@ -1667,14 +1675,18 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo [Fact] public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() { - _factory.Clock.Reset(); + using var factory = CreateDetailedFailureFactory(); + factory.Clock.Reset(); - var user = await _factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var user = await factory.CreateLoginUserAsync( + ct: TestContext.Current.CancellationToken); using var client1 = CreateClient( + factory, $"concurrent-failure-1-{Guid.NewGuid():N}"); using var client2 = CreateClient( + factory, $"concurrent-failure-2-{Guid.NewGuid():N}"); var task1 = TryLoginAsync( @@ -1692,6 +1704,7 @@ public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() results.Should().OnlyContain(x => !x.IsSuccess); using var verificationClient = CreateClient( + factory, $"concurrent-failure-verification-{Guid.NewGuid():N}"); var verification = await TryLoginAsync( @@ -1838,10 +1851,20 @@ public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptTo "another-wrong-password"); failureState.IsSuccess.Should().BeFalse(); - failureState.Reason.Should().Be(AuthFailureReason.LockedOut); + failureState.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + failureState.RemainingAttempts.Should().BeNull(); + failureState.LockoutUntilUtc.Should().BeNull(); } + private static AuthServerFactory CreateDetailedFailureFactory() + { + return AuthServerFactory.Create(options => + { + options.Login.IncludeFailureDetails = true; + }); + } + private HttpClient CreateClient(string deviceId = "test-device-1234567890123456") { var client = _factory.CreateClient( @@ -1862,6 +1885,21 @@ private HttpClient CreateClient(string deviceId = "test-device-1234567890123456" return client; } + private static HttpClient CreateClient(AuthServerFactory factory, string deviceId) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", deviceId); + + return client; + } + private static Task LoginAsync(HttpClient client, string identifier, string secret) { return CsrfTestClient.PostJsonAsync(client, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs index 56b021fe..c76dfcce 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs @@ -1,36 +1,34 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; -using AwesomeAssertions; using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Text; -namespace CodeBeam.UltimateAuth.Tests.Unit.Server +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public class TryLoginTests { - public class TryLoginTests + [Fact] + public async Task TryLogin_Should_Return_Preview_On_Failure() { - [Fact] - public async Task TryLogin_Should_Return_Preview_On_Failure() - { - var runtime = new TestAuthRuntime(); + var runtime = new TestAuthRuntime(); - using var scope = runtime.Services.CreateScope(); + using var scope = runtime.Services.CreateScope(); - var flow = await runtime.CreateLoginFlowAsync(); - var orchestrator = scope.ServiceProvider.GetRequiredService(); + var flow = await runtime.CreateLoginFlowAsync(); + var orchestrator = scope.ServiceProvider.GetRequiredService(); - var result = await orchestrator.LoginAsync(flow, new LoginRequest - { - Identifier = "user", - Secret = "wrong" - }, ct: TestContext.Current.CancellationToken); - - result.Should().NotBeNull(); - result.IsSuccess.Should().BeFalse(); + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "wrong" + }, ct: TestContext.Current.CancellationToken); - result.RemainingAttempts.Should().NotBeNull(); - } + result.Should().NotBeNull(); + result.IsSuccess.Should().BeFalse(); + result.FailureReason.Should().Be(AuthFailureReason.InvalidCredentials); + result.RemainingAttempts.Should().BeNull(); + result.LockoutUntilUtc.Should().BeNull(); } }