From 86a45eff3e7cf1582b92791b6f2dfa08e8d4fa2c Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 6 Oct 2026 16:53:46 +0100 Subject: [PATCH 01/22] Add MFA persistence --- .../DbContexts/SecurityServiceDbContext.cs | 25 + .../Entities/MfaPolicy.cs | 18 + .../Entities/RecoveryCode.cs | 14 + .../20261006155308_AddMfaSupport.Designer.cs | 714 ++++++++++++++++++ .../20261006155308_AddMfaSupport.cs | 77 ++ .../SecurityServiceDbContextModelSnapshot.cs | 79 +- .../Database/MfaPersistenceTests.cs | 60 ++ 7 files changed, 981 insertions(+), 6 deletions(-) create mode 100644 SecurityService.Database/Entities/MfaPolicy.cs create mode 100644 SecurityService.Database/Entities/RecoveryCode.cs create mode 100644 SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs create mode 100644 SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs create mode 100644 SecurityService.UnitTests/Database/MfaPersistenceTests.cs diff --git a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs index 24007b4b..b4ca93c4 100644 --- a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs +++ b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs @@ -17,6 +17,10 @@ public SecurityServiceDbContext(DbContextOptions optio public DbSet ResourceDefinitions => this.Set(); + public DbSet MfaPolicies => this.Set(); + + public DbSet RecoveryCodes => this.Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -39,5 +43,26 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(resource => resource.Name).HasMaxLength(200); entity.Property(resource => resource.Type).HasConversion(new EnumToStringConverter()); }); + + builder.Entity(entity => + { + entity.HasKey(policy => policy.Id); + entity.Property(policy => policy.TargetType).HasConversion(); + entity.Property(policy => policy.TargetId).HasMaxLength(450).IsRequired(); + entity.HasIndex(policy => new { policy.TargetType, policy.TargetId }).IsUnique(); + }); + + builder.Entity(entity => + { + entity.HasKey(code => code.Id); + entity.Property(code => code.UserId).HasMaxLength(450).IsRequired(); + entity.Property(code => code.CodeHash).HasMaxLength(200).IsRequired(); + entity.HasIndex(code => new { code.UserId, code.CodeHash }).IsUnique(); + entity.HasIndex(code => new { code.UserId, code.ConsumedUtc }); + entity.HasOne() + .WithMany() + .HasForeignKey(code => code.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/SecurityService.Database/Entities/MfaPolicy.cs b/SecurityService.Database/Entities/MfaPolicy.cs new file mode 100644 index 00000000..dc7ca520 --- /dev/null +++ b/SecurityService.Database/Entities/MfaPolicy.cs @@ -0,0 +1,18 @@ +namespace SecurityService.Database.Entities; + +public enum MfaPolicyTargetType +{ + User = 1, + Role = 2 +} + +public sealed class MfaPolicy +{ + public Guid Id { get; set; } + + public MfaPolicyTargetType TargetType { get; set; } + + public string TargetId { get; set; } = string.Empty; + + public DateTime CreatedUtc { get; set; } +} diff --git a/SecurityService.Database/Entities/RecoveryCode.cs b/SecurityService.Database/Entities/RecoveryCode.cs new file mode 100644 index 00000000..c4b282d0 --- /dev/null +++ b/SecurityService.Database/Entities/RecoveryCode.cs @@ -0,0 +1,14 @@ +namespace SecurityService.Database.Entities; + +public sealed class RecoveryCode +{ + public Guid Id { get; set; } + + public string UserId { get; set; } = string.Empty; + + public string CodeHash { get; set; } = string.Empty; + + public DateTime CreatedUtc { get; set; } + + public DateTime? ConsumedUtc { get; set; } +} diff --git a/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs new file mode 100644 index 00000000..cdd897dd --- /dev/null +++ b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs @@ -0,0 +1,714 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SecurityService.Database.DbContexts; + +#nullable disable + +namespace SecurityService.Database.Migrations +{ + [DbContext(typeof(SecurityServiceDbContext))] + [Migration("20261006155308_AddMfaSupport")] + partial class AddMfaSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayNames") + .HasColumnType("nvarchar(max)"); + + b.Property("JsonWebKeySet") + .HasColumnType("nvarchar(max)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("nvarchar(max)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUris") + .HasColumnType("nvarchar(max)"); + + b.Property("Requirements") + .HasColumnType("nvarchar(max)"); + + b.Property("Settings") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL"); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayNames") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("Resources") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[Name] IS NOT NULL"); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("AuthorizationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("Payload") + .HasColumnType("nvarchar(max)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RedemptionDate") + .HasColumnType("datetime2"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("[ReferenceId] IS NOT NULL"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RegistrationDateTime") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ClientDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AllowOfflineAccess") + .HasColumnType("bit"); + + b.Property("AllowedGrantTypesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AllowedScopesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ClientUri") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUrisJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUrisJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RequireConsent") + .HasColumnType("bit"); + + b.Property("SecretHash") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("ClientDefinitions"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.MfaPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .IsUnique(); + + b.ToTable("MfaPolicies"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ConsumedUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CodeHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedUtc"); + + b.ToTable("RecoveryCodes"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ResourceDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClaimsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Emphasize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Required") + .HasColumnType("bit"); + + b.Property("ScopesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SecretHash") + .HasColumnType("nvarchar(max)"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("bit"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "Type") + .IsUnique(); + + b.ToTable("ResourceDefinitions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs new file mode 100644 index 00000000..71aa6dec --- /dev/null +++ b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecurityService.Database.Migrations +{ + /// + public partial class AddMfaSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MfaPolicies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TargetType = table.Column(type: "nvarchar(450)", nullable: false), + TargetId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + CreatedUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MfaPolicies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RecoveryCodes", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + CodeHash = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedUtc = table.Column(type: "datetime2", nullable: false), + ConsumedUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RecoveryCodes", x => x.Id); + table.ForeignKey( + name: "FK_RecoveryCodes_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MfaPolicies_TargetType_TargetId", + table: "MfaPolicies", + columns: new[] { "TargetType", "TargetId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RecoveryCodes_UserId_CodeHash", + table: "RecoveryCodes", + columns: new[] { "UserId", "CodeHash" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RecoveryCodes_UserId_ConsumedUtc", + table: "RecoveryCodes", + columns: new[] { "UserId", "ConsumedUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MfaPolicies"); + + migrationBuilder.DropTable( + name: "RecoveryCodes"); + } + } +} diff --git a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs index b6943b92..92a8bc28 100644 --- a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs +++ b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -366,7 +366,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OpenIddictTokens", (string)null); }); - modelBuilder.Entity("SecurityService.Database.ApplicationUser", b => + modelBuilder.Entity("SecurityService.Database.Entities.ApplicationUser", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -503,6 +503,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ClientDefinitions"); }); + modelBuilder.Entity("SecurityService.Database.Entities.MfaPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .IsUnique(); + + b.ToTable("MfaPolicies"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ConsumedUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CodeHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedUtc"); + + b.ToTable("RecoveryCodes"); + }); + modelBuilder.Entity("SecurityService.Database.Entities.ResourceDefinition", b => { b.Property("Id") @@ -563,7 +621,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -572,7 +630,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -587,7 +645,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -596,7 +654,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -627,6 +685,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Authorization"); }); + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => { b.Navigation("Authorizations"); diff --git a/SecurityService.UnitTests/Database/MfaPersistenceTests.cs b/SecurityService.UnitTests/Database/MfaPersistenceTests.cs new file mode 100644 index 00000000..ab7290b0 --- /dev/null +++ b/SecurityService.UnitTests/Database/MfaPersistenceTests.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using Shouldly; + +namespace SecurityService.UnitTests.Database; + +public sealed class MfaPersistenceTests +{ + [Fact] + public void Model_contains_mfa_policy_and_recovery_code_sets_with_expected_indexes() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new SecurityServiceDbContext(options); + + context.MfaPolicies.ShouldNotBeNull(); + context.RecoveryCodes.ShouldNotBeNull(); + + var policyIndexes = context.Model.FindEntityType(typeof(MfaPolicy))!.GetIndexes(); + policyIndexes.Any(index => index.Properties.Select(property => property.Name).SequenceEqual(new[] { nameof(MfaPolicy.TargetType), nameof(MfaPolicy.TargetId) }) && index.IsUnique).ShouldBeTrue(); + + var recoveryIndexes = context.Model.FindEntityType(typeof(RecoveryCode))!.GetIndexes(); + recoveryIndexes.Any(index => index.Properties.Select(property => property.Name).SequenceEqual(new[] { nameof(RecoveryCode.UserId), nameof(RecoveryCode.ConsumedUtc) })).ShouldBeTrue(); + } + + [Fact] + public async Task Recovery_code_records_persist_hash_and_consumed_timestamp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var userId = Guid.NewGuid().ToString(); + var consumedUtc = DateTime.UtcNow; + + await using (var context = new SecurityServiceDbContext(options)) + { + context.RecoveryCodes.Add(new RecoveryCode + { + Id = Guid.NewGuid(), + UserId = userId, + CodeHash = "hash", + CreatedUtc = DateTime.UtcNow, + ConsumedUtc = consumedUtc + }); + await context.SaveChangesAsync(); + } + + await using (var context = new SecurityServiceDbContext(options)) + { + var code = await context.RecoveryCodes.SingleAsync(); + code.UserId.ShouldBe(userId); + code.CodeHash.ShouldBe("hash"); + code.ConsumedUtc.ShouldBe(consumedUtc); + } + } +} From 4fc5bb6db84370f904addcd616467ae764ff51ea Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 6 Oct 2026 16:58:28 +0100 Subject: [PATCH 02/22] Add management API MFA policies --- .../Mfa/MfaPolicyService.cs | 115 ++++++++++++++++++ .../MfaPolicyRequestHandler.cs | 32 +++++ .../Requests/SecurityServiceCommands.cs | 5 + .../Authorization/MfaPolicyServiceTests.cs | 87 +++++++++++++ .../MfaPolicyRequestHandlerTests.cs | 40 ++++++ .../Endpoints/ManagementEndpoints.cs | 4 + SecurityService/Handlers/RoleHandler.cs | 16 +++ SecurityService/Handlers/UserHandler.cs | 16 +++ SecurityService/Program.cs | 2 + 9 files changed, 317 insertions(+) create mode 100644 SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs create mode 100644 SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs create mode 100644 SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs create mode 100644 SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs diff --git a/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs b/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs new file mode 100644 index 00000000..171cc643 --- /dev/null +++ b/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using SimpleResults; + +namespace SecurityService.BusinessLogic.Mfa; + +public interface IMfaPolicyService +{ + Task IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken); +} + +public sealed class MfaPolicyService : IMfaPolicyService +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly SecurityServiceDbContext _dbContext; + + public MfaPolicyService( + UserManager userManager, + RoleManager roleManager, + SecurityServiceDbContext dbContext) + { + _userManager = userManager; + _roleManager = roleManager; + _dbContext = dbContext; + } + + public async Task IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var hasUserPolicy = await _dbContext.MfaPolicies + .AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.User && policy.TargetId == user.Id, cancellationToken); + if (hasUserPolicy) + { + return true; + } + + var roleNames = await _userManager.GetRolesAsync(user); + if (roleNames.Count == 0) + { + return false; + } + + var roleIds = await _roleManager.Roles + .Where(role => role.Name != null && roleNames.Contains(role.Name)) + .Select(role => role.Id) + .ToListAsync(cancellationToken); + + return await _dbContext.MfaPolicies + .AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.Role && roleIds.Contains(policy.TargetId), cancellationToken); + } + + public async Task RequireUserAsync(string userId, CancellationToken cancellationToken) + { + if (await _userManager.FindByIdAsync(userId) is null) + { + return Result.NotFound($"No user found with id '{userId}'."); + } + + return await RequireAsync(MfaPolicyTargetType.User, userId, cancellationToken); + } + + public Task RemoveUserAsync(string userId, CancellationToken cancellationToken) => + RemoveAsync(MfaPolicyTargetType.User, userId, cancellationToken); + + public async Task RequireRoleAsync(string roleId, CancellationToken cancellationToken) + { + if (await _roleManager.FindByIdAsync(roleId) is null) + { + return Result.NotFound($"No role found with id '{roleId}'."); + } + + return await RequireAsync(MfaPolicyTargetType.Role, roleId, cancellationToken); + } + + public Task RemoveRoleAsync(string roleId, CancellationToken cancellationToken) => + RemoveAsync(MfaPolicyTargetType.Role, roleId, cancellationToken); + + private async Task RequireAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken) + { + var exists = await _dbContext.MfaPolicies.AnyAsync( + policy => policy.TargetType == targetType && policy.TargetId == targetId, + cancellationToken); + if (exists) + { + return Result.Success(); + } + + _dbContext.MfaPolicies.Add(new MfaPolicy + { + Id = Guid.NewGuid(), + TargetType = targetType, + TargetId = targetId, + CreatedUtc = DateTime.UtcNow + }); + await _dbContext.SaveChangesAsync(cancellationToken); + return Result.Success(); + } + + private async Task RemoveAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken) + { + var policies = await _dbContext.MfaPolicies + .Where(policy => policy.TargetType == targetType && policy.TargetId == targetId) + .ToListAsync(cancellationToken); + if (policies.Count == 0) + { + return Result.Success(); + } + + _dbContext.MfaPolicies.RemoveRange(policies); + await _dbContext.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} diff --git a/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs new file mode 100644 index 00000000..537f66f7 --- /dev/null +++ b/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.BusinessLogic.Requests; +using SimpleResults; + +namespace SecurityService.BusinessLogic.RequestHandlers; + +public sealed class MfaPolicyRequestHandler : + IRequestHandler, + IRequestHandler, + IRequestHandler, + IRequestHandler +{ + private readonly MfaPolicyService _mfaPolicyService; + + public MfaPolicyRequestHandler(MfaPolicyService mfaPolicyService) + { + _mfaPolicyService = mfaPolicyService; + } + + public Task Handle(SecurityServiceCommands.RequireUserMfaCommand command, CancellationToken cancellationToken) => + _mfaPolicyService.RequireUserAsync(command.UserId, cancellationToken); + + public Task Handle(SecurityServiceCommands.RemoveUserMfaCommand command, CancellationToken cancellationToken) => + _mfaPolicyService.RemoveUserAsync(command.UserId, cancellationToken); + + public Task Handle(SecurityServiceCommands.RequireRoleMfaCommand command, CancellationToken cancellationToken) => + _mfaPolicyService.RequireRoleAsync(command.RoleId, cancellationToken); + + public Task Handle(SecurityServiceCommands.RemoveRoleMfaCommand command, CancellationToken cancellationToken) => + _mfaPolicyService.RemoveRoleAsync(command.RoleId, cancellationToken); +} diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs index 09d322cf..7af6aa34 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs @@ -70,4 +70,9 @@ public record ProcessPasswordResetConfirmationCommand(String Username, public record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest; public record RevokeGrantCommand(string UserId, string AuthorizationId) : IRequest; + + public record RequireUserMfaCommand(String UserId) : IRequest; + public record RemoveUserMfaCommand(String UserId) : IRequest; + public record RequireRoleMfaCommand(String RoleId) : IRequest; + public record RemoveRoleMfaCommand(String RoleId) : IRequest; } diff --git a/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs b/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs new file mode 100644 index 00000000..7f001e5f --- /dev/null +++ b/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.Authorization; + +public sealed class MfaPolicyServiceTests +{ + [Fact] + public async Task IsMfaRequired_returns_false_when_user_and_roles_have_no_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var service = CreateService(scope); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + [Fact] + public async Task IsMfaRequired_returns_true_for_direct_user_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task IsMfaRequired_returns_true_for_any_role_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + (await roleManager.CreateAsync(new IdentityRole("Estate"))).Succeeded.ShouldBeTrue(); + (await userManager.AddToRoleAsync(user, "Estate")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var role = await roleManager.FindByNameAsync("Estate"); + (await service.RequireRoleAsync(role!.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task Require_and_remove_operations_are_idempotent() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RemoveUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RemoveUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + private static MfaPolicyService CreateService(IServiceScope scope) + { + return new MfaPolicyService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs b/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs new file mode 100644 index 00000000..a70603e3 --- /dev/null +++ b/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.BusinessLogic.RequestHandlers; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.RequestHandlers; + +public sealed class MfaPolicyRequestHandlerTests +{ + [Fact] + public async Task User_policy_commands_are_idempotent_and_remove_the_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var handler = new MfaPolicyRequestHandler(CreateService(scope)); + + (await handler.Handle(new SecurityServiceCommands.RequireUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await handler.Handle(new SecurityServiceCommands.RequireUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await handler.Handle(new SecurityServiceCommands.RemoveUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + var service = CreateService(scope); + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + private static MfaPolicyService CreateService(IServiceScope scope) + { + return new MfaPolicyService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/SecurityService/Endpoints/ManagementEndpoints.cs b/SecurityService/Endpoints/ManagementEndpoints.cs index 3a011538..81f995fd 100644 --- a/SecurityService/Endpoints/ManagementEndpoints.cs +++ b/SecurityService/Endpoints/ManagementEndpoints.cs @@ -32,5 +32,9 @@ public static void MapManagementEndpoints(this IEndpointRouteBuilder endpoints) management.MapPost("/users", Handlers.UserHandler.CreateUser).WithName("CreateUser"); management.MapGet("/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser"); management.MapGet("/users", Handlers.UserHandler.GetUsers).WithName("GetUsers"); + management.MapPut("/users/{userId}/mfa-policy", Handlers.UserHandler.RequireMfa).WithName("RequireUserMfa"); + management.MapDelete("/users/{userId}/mfa-policy", Handlers.UserHandler.RemoveMfa).WithName("RemoveUserMfa"); + management.MapPut("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RequireMfa).WithName("RequireRoleMfa"); + management.MapDelete("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RemoveMfa).WithName("RemoveRoleMfa"); } } diff --git a/SecurityService/Handlers/RoleHandler.cs b/SecurityService/Handlers/RoleHandler.cs index 11da65f9..5b15f1ab 100644 --- a/SecurityService/Handlers/RoleHandler.cs +++ b/SecurityService/Handlers/RoleHandler.cs @@ -10,6 +10,22 @@ namespace SecurityService.Handlers; public static class RoleHandler { + public static async Task RequireMfa(IMediator mediator, + string roleId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RequireRoleMfaCommand(roleId), cancellationToken); + return ResponseFactory.FromResult(result); + } + + public static async Task RemoveMfa(IMediator mediator, + string roleId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RemoveRoleMfaCommand(roleId), cancellationToken); + return ResponseFactory.FromResult(result); + } + public static async Task CreateRole(IMediator mediator, CreateRoleRequest request, CancellationToken cancellationToken) { SecurityServiceCommands.CreateRoleCommand command = new(request.RoleName); diff --git a/SecurityService/Handlers/UserHandler.cs b/SecurityService/Handlers/UserHandler.cs index 357ee2b3..9d032735 100644 --- a/SecurityService/Handlers/UserHandler.cs +++ b/SecurityService/Handlers/UserHandler.cs @@ -10,6 +10,22 @@ namespace SecurityService.Handlers; public static class UserHandler { + public static async Task RequireMfa(IMediator mediator, + string userId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RequireUserMfaCommand(userId), cancellationToken); + return ResponseFactory.FromResult(result); + } + + public static async Task RemoveMfa(IMediator mediator, + string userId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RemoveUserMfaCommand(userId), cancellationToken); + return ResponseFactory.FromResult(result); + } + public static async Task CreateUser(IMediator mediator, CreateUserRequest request, CancellationToken cancellationToken) { SecurityServiceCommands.CreateUserCommand command = new(request.GivenName, request.MiddleName, request.FamilyName, request.UserName, request.Password, request.EmailAddress, request.PhoneNumber, request.Claims, request.Roles); diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index 76a0c4a1..d50d78af 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -12,6 +12,7 @@ using NLog.Extensions.Logging; using OpenIddict.Abstractions; using SecurityService.BusinessLogic; +using SecurityService.BusinessLogic.Mfa; using SecurityService.BusinessLogic.Oidc; using SecurityService.BusinessLogic.Requests; using SecurityService.Common; @@ -210,6 +211,7 @@ builder.Services.AddSingleton(serviceProvider => new ConsentTransactionProtector( serviceProvider.GetRequiredService().CreateProtector("SecurityService.Consent"))); builder.Services.AddScoped(); +builder.Services.AddScoped(); if (builder.Environment.IsEnvironment("IntegrationTest")) { From 9d7da1994d7e133ba2e8c2f23bb01956687b8ce3 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 6 Oct 2026 17:08:58 +0100 Subject: [PATCH 03/22] Add TOTP enrollment and recovery codes --- .../Mfa/MfaAccountService.cs | 156 ++++ .../Mfa/RecoveryCodeGenerator.cs | 30 + .../DbContexts/SecurityServiceDbContext.cs | 1 + .../Entities/RecoveryCode.cs | 2 + ...427_AddRecoveryCodeConcurrency.Designer.cs | 720 ++++++++++++++++++ ...261006160427_AddRecoveryCodeConcurrency.cs | 30 + .../SecurityServiceDbContextModelSnapshot.cs | 6 + .../Mfa/MfaAccountServiceTests.cs | 120 +++ .../Mfa/RecoveryCodeGeneratorTests.cs | 19 + .../Pages/Account/ManageMfa/Index.cshtml | 63 ++ .../Pages/Account/ManageMfa/Index.cshtml.cs | 92 +++ .../Account/ManageMfa/RecoveryCodes.cshtml | 46 ++ .../Account/ManageMfa/RecoveryCodes.cshtml.cs | 51 ++ SecurityService/Pages/Shared/_Nav.cshtml | 1 + SecurityService/Program.cs | 2 + 15 files changed, 1339 insertions(+) create mode 100644 SecurityService.BusinessLogic/Mfa/MfaAccountService.cs create mode 100644 SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs create mode 100644 SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs create mode 100644 SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs create mode 100644 SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs create mode 100644 SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs create mode 100644 SecurityService/Pages/Account/ManageMfa/Index.cshtml create mode 100644 SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs create mode 100644 SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml create mode 100644 SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs diff --git a/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs b/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs new file mode 100644 index 00000000..5463b932 --- /dev/null +++ b/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs @@ -0,0 +1,156 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; + +namespace SecurityService.BusinessLogic.Mfa; + +public sealed record MfaEnrollment(string AuthenticatorKey, string OtpauthUri); + +public sealed class MfaAccountService +{ + private const string AuthenticatorProvider = "Authenticator"; + private readonly UserManager _userManager; + private readonly IPasswordHasher _passwordHasher; + private readonly SecurityServiceDbContext _dbContext; + private readonly RecoveryCodeGenerator _recoveryCodeGenerator; + + public MfaAccountService( + UserManager userManager, + IPasswordHasher passwordHasher, + SecurityServiceDbContext dbContext, + RecoveryCodeGenerator recoveryCodeGenerator) + { + _userManager = userManager; + _passwordHasher = passwordHasher; + _dbContext = dbContext; + _recoveryCodeGenerator = recoveryCodeGenerator; + } + + public async Task BeginEnrollmentAsync(ApplicationUser user, string issuer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var resetResult = await _userManager.ResetAuthenticatorKeyAsync(user); + if (!resetResult.Succeeded) + { + throw new InvalidOperationException(string.Join("; ", resetResult.Errors.Select(error => error.Description))); + } + + var key = await _userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrWhiteSpace(key)) + { + throw new InvalidOperationException("The authenticator key could not be generated."); + } + + var account = user.Email ?? user.UserName ?? user.Id; + var uri = $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(account)}?secret={key}&issuer={Uri.EscapeDataString(issuer)}"; + return new MfaEnrollment(key, uri); + } + + public async Task ConfirmEnrollmentAsync(ApplicationUser user, string code, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(code)) + { + return false; + } + + var valid = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code); + if (valid == false) + { + return false; + } + + return (await _userManager.SetTwoFactorEnabledAsync(user, true)).Succeeded; + } + + public async Task> GenerateRecoveryCodesAsync(ApplicationUser user, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var existingCodes = await _dbContext.RecoveryCodes.Where(code => code.UserId == user.Id).ToListAsync(cancellationToken); + _dbContext.RecoveryCodes.RemoveRange(existingCodes); + + var codes = _recoveryCodeGenerator.Generate(count); + foreach (var code in codes) + { + _dbContext.RecoveryCodes.Add(new RecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = _passwordHasher.HashPassword(user, code), + CreatedUtc = DateTime.UtcNow + }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return codes; + } + + public async Task RedeemRecoveryCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(code)) + { + return false; + } + + var candidates = await _dbContext.RecoveryCodes + .Where(recoveryCode => recoveryCode.UserId == user.Id && recoveryCode.ConsumedUtc == null) + .ToListAsync(cancellationToken); + foreach (var candidate in candidates) + { + var verification = _passwordHasher.VerifyHashedPassword(user, candidate.CodeHash, code); + if (verification == PasswordVerificationResult.Failed) + { + continue; + } + + candidate.ConsumedUtc = DateTime.UtcNow; + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + } + + return false; + } + + public async Task RemoveAuthenticatorAsync(ApplicationUser user, string currentPassword, string? code, CancellationToken cancellationToken) + { + var reauthenticated = await _userManager.CheckPasswordAsync(user, currentPassword); + if (reauthenticated == false && string.IsNullOrWhiteSpace(code) == false) + { + reauthenticated = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code); + } + + if (reauthenticated == false) + { + return false; + } + + var disableResult = await _userManager.SetTwoFactorEnabledAsync(user, false); + if (disableResult.Succeeded == false) + { + return false; + } + + await _userManager.ResetAuthenticatorKeyAsync(user); + var recoveryCodes = await _dbContext.RecoveryCodes.Where(recoveryCode => recoveryCode.UserId == user.Id).ToListAsync(cancellationToken); + _dbContext.RecoveryCodes.RemoveRange(recoveryCodes); + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task RevokeRecoveryCodesAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var recoveryCodes = await _dbContext.RecoveryCodes + .Where(recoveryCode => recoveryCode.UserId == user.Id) + .ToListAsync(cancellationToken); + _dbContext.RecoveryCodes.RemoveRange(recoveryCodes); + await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs b/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs new file mode 100644 index 00000000..20196844 --- /dev/null +++ b/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; + +namespace SecurityService.BusinessLogic.Mfa; + +public sealed class RecoveryCodeGenerator +{ + private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + public IReadOnlyList Generate(int count) + { + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + var codes = new List(count); + for (var codeIndex = 0; codeIndex < count; codeIndex++) + { + var characters = new char[16]; + for (var characterIndex = 0; characterIndex < characters.Length; characterIndex++) + { + characters[characterIndex] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + } + + codes.Add(new string(characters)); + } + + return codes; + } +} diff --git a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs index b4ca93c4..a8a74af8 100644 --- a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs +++ b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs @@ -57,6 +57,7 @@ protected override void OnModelCreating(ModelBuilder builder) entity.HasKey(code => code.Id); entity.Property(code => code.UserId).HasMaxLength(450).IsRequired(); entity.Property(code => code.CodeHash).HasMaxLength(200).IsRequired(); + entity.Property(code => code.ConcurrencyStamp).HasMaxLength(32).IsConcurrencyToken().IsRequired(); entity.HasIndex(code => new { code.UserId, code.CodeHash }).IsUnique(); entity.HasIndex(code => new { code.UserId, code.ConsumedUtc }); entity.HasOne() diff --git a/SecurityService.Database/Entities/RecoveryCode.cs b/SecurityService.Database/Entities/RecoveryCode.cs index c4b282d0..05078398 100644 --- a/SecurityService.Database/Entities/RecoveryCode.cs +++ b/SecurityService.Database/Entities/RecoveryCode.cs @@ -8,6 +8,8 @@ public sealed class RecoveryCode public string CodeHash { get; set; } = string.Empty; + public string ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString("N"); + public DateTime CreatedUtc { get; set; } public DateTime? ConsumedUtc { get; set; } diff --git a/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs new file mode 100644 index 00000000..177535b2 --- /dev/null +++ b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs @@ -0,0 +1,720 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SecurityService.Database.DbContexts; + +#nullable disable + +namespace SecurityService.Database.Migrations +{ + [DbContext(typeof(SecurityServiceDbContext))] + [Migration("20261006160427_AddRecoveryCodeConcurrency")] + partial class AddRecoveryCodeConcurrency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayNames") + .HasColumnType("nvarchar(max)"); + + b.Property("JsonWebKeySet") + .HasColumnType("nvarchar(max)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("nvarchar(max)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUris") + .HasColumnType("nvarchar(max)"); + + b.Property("Requirements") + .HasColumnType("nvarchar(max)"); + + b.Property("Settings") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL"); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayNames") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("Resources") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[Name] IS NOT NULL"); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("AuthorizationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("Payload") + .HasColumnType("nvarchar(max)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RedemptionDate") + .HasColumnType("datetime2"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("[ReferenceId] IS NOT NULL"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RegistrationDateTime") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ClientDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AllowOfflineAccess") + .HasColumnType("bit"); + + b.Property("AllowedGrantTypesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AllowedScopesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ClientUri") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUrisJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUrisJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RequireConsent") + .HasColumnType("bit"); + + b.Property("SecretHash") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("ClientDefinitions"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.MfaPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .IsUnique(); + + b.ToTable("MfaPolicies"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ConsumedUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CodeHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedUtc"); + + b.ToTable("RecoveryCodes"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.ResourceDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClaimsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Emphasize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Required") + .HasColumnType("bit"); + + b.Property("ScopesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SecretHash") + .HasColumnType("nvarchar(max)"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("bit"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "Type") + .IsUnique(); + + b.ToTable("ResourceDefinitions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b => + { + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs new file mode 100644 index 00000000..6ea02980 --- /dev/null +++ b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecurityService.Database.Migrations +{ + /// + public partial class AddRecoveryCodeConcurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "RecoveryCodes", + type: "nvarchar(32)", + maxLength: 32, + nullable: false, + defaultValueSql: "CONVERT(nvarchar(32), NEWID())"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ConcurrencyStamp", + table: "RecoveryCodes"); + } + } +} diff --git a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs index 92a8bc28..89f8acec 100644 --- a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs +++ b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs @@ -540,6 +540,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + b.Property("ConsumedUtc") .HasColumnType("datetime2"); diff --git a/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs b/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs new file mode 100644 index 00000000..9cc50b5d --- /dev/null +++ b/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Buffers.Binary; +using System.Security.Cryptography; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.Mfa; + +public sealed class MfaAccountServiceTests +{ + [Fact] + public async Task Recovery_codes_are_hashed_returned_once_and_consumed_once() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var codes = await service.GenerateRecoveryCodesAsync(user, 2, CancellationToken.None); + + codes.Count.ShouldBe(2); + var stored = await scope.ServiceProvider.GetRequiredService().RecoveryCodes.ToListAsync(); + stored.Count.ShouldBe(2); + stored.ShouldAllBe(code => codes.Contains(code.CodeHash) == false); + (await service.RedeemRecoveryCodeAsync(user, codes[0], CancellationToken.None)).ShouldBeTrue(); + (await service.RedeemRecoveryCodeAsync(user, codes[0], CancellationToken.None)).ShouldBeFalse(); + + var consumed = stored.Single(code => code.ConsumedUtc is not null); + consumed.ConsumedUtc.ShouldNotBeNull(); + } + + [Fact] + public async Task Confirm_enrollment_enables_identity_two_factor_only_after_valid_code() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user", Email = "mfa@example.com" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var enrollment = await service.BeginEnrollmentAsync(user, "SecurityService", CancellationToken.None); + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeFalse(); + var token = GenerateAuthenticatorCode(enrollment.AuthenticatorKey); + + (await service.ConfirmEnrollmentAsync(user, "000000", CancellationToken.None)).ShouldBeFalse(); + (await service.ConfirmEnrollmentAsync(user, token, CancellationToken.None)).ShouldBeTrue(); + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeTrue(); + enrollment.AuthenticatorKey.ShouldNotBeNullOrWhiteSpace(); + enrollment.OtpauthUri.ShouldStartWith("otpauth://totp/"); + } + + [Fact] + public async Task Removing_authenticator_requires_reauthentication_and_revokes_recovery_codes() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user", Email = "mfa@example.com" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + var enrollment = await service.BeginEnrollmentAsync(user, "SecurityService", CancellationToken.None); + var token = GenerateAuthenticatorCode(enrollment.AuthenticatorKey); + (await service.ConfirmEnrollmentAsync(user, token, CancellationToken.None)).ShouldBeTrue(); + await service.GenerateRecoveryCodesAsync(user, 2, CancellationToken.None); + + (await service.RemoveAuthenticatorAsync(user, "wrong", "000000", CancellationToken.None)).ShouldBeFalse(); + (await service.RemoveAuthenticatorAsync(user, "Pa55word!", null, CancellationToken.None)).ShouldBeTrue(); + + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeFalse(); + (await scope.ServiceProvider.GetRequiredService().RecoveryCodes.CountAsync()).ShouldBe(0); + enrollment.AuthenticatorKey.ShouldNotBeNullOrWhiteSpace(); + } + + private static MfaAccountService CreateService(IServiceScope scope) + { + return new MfaAccountService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService(), + new RecoveryCodeGenerator()); + } + + private static string GenerateAuthenticatorCode(string key) + { + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var bits = new List(); + var buffer = 0; + var bitCount = 0; + foreach (var character in key) + { + buffer = (buffer << 5) | alphabet.IndexOf(char.ToUpperInvariant(character)); + bitCount += 5; + while (bitCount >= 8) + { + bitCount -= 8; + bits.Add((byte)(buffer >> bitCount)); + } + } + + var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + Span counterBytes = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(counterBytes, counter); + using var hmac = new HMACSHA1(bits.ToArray()); + var hash = hmac.ComputeHash(counterBytes.ToArray()); + var offset = hash[^1] & 0x0f; + var value = ((hash[offset] & 0x7f) << 24) | + (hash[offset + 1] << 16) | + (hash[offset + 2] << 8) | + hash[offset + 3]; + return (value % 1_000_000).ToString("D6"); + } +} diff --git a/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs b/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs new file mode 100644 index 00000000..3c8933a9 --- /dev/null +++ b/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs @@ -0,0 +1,19 @@ +using SecurityService.BusinessLogic.Mfa; +using Shouldly; + +namespace SecurityService.UnitTests.Mfa; + +public sealed class RecoveryCodeGeneratorTests +{ + [Fact] + public void Generate_returns_unique_codes_with_expected_shape() + { + var generator = new RecoveryCodeGenerator(); + + var codes = generator.Generate(10); + + codes.Count.ShouldBe(10); + codes.Distinct(StringComparer.Ordinal).Count().ShouldBe(10); + codes.ShouldAllBe(code => code.Length == 16 && code.All(char.IsLetterOrDigit)); + } +} diff --git a/SecurityService/Pages/Account/ManageMfa/Index.cshtml b/SecurityService/Pages/Account/ManageMfa/Index.cshtml new file mode 100644 index 00000000..e4ee8392 --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/Index.cshtml @@ -0,0 +1,63 @@ +@page +@model SecurityService.Pages.Account.ManageMfa.IndexModel +@{ + ViewData["Title"] = "Manage MFA"; +} + +
+
+

Manage MFA

+

Protect your account with an authenticator app.

+
+ + @if (string.IsNullOrWhiteSpace(Model.StatusMessage) == false) + { +
@Model.StatusMessage
+ } + + + +
+
+ @if (Model.IsMfaEnabled) + { +

MFA is enabled

+

Use your authenticator app or a recovery code when you sign in.

+
+
+ + +
+
+ + +
+ +
+

Manage recovery codes

+ } + else if (Model.Enrollment is null) + { +

MFA is not enabled

+

Set up an authenticator app to add a second factor to your account.

+
+ +
+ } + else + { +

Scan the QR code

+

Add this account to your authenticator app, then enter the six-digit code it shows.

+

Setup key: @Model.Enrollment.AuthenticatorKey

+

Authenticator URI: @Model.Enrollment.OtpauthUri

+
+
+ + +
+ +
+ } +
+
+
diff --git a/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs b/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs new file mode 100644 index 00000000..c61a19a7 --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.Entities; + +namespace SecurityService.Pages.Account.ManageMfa; + +[Authorize] +public sealed class IndexModel : PageModel +{ + private readonly UserManager _userManager; + private readonly MfaAccountService _mfaAccountService; + + public IndexModel(UserManager userManager, MfaAccountService mfaAccountService) + { + _userManager = userManager; + _mfaAccountService = mfaAccountService; + } + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public MfaEnrollment? Enrollment { get; private set; } + + public bool IsMfaEnabled { get; private set; } + + public string StatusMessage { get; private set; } = string.Empty; + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostBeginEnrollmentAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + this.Enrollment = await _mfaAccountService.BeginEnrollmentAsync(user, "SecurityService", cancellationToken); + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostConfirmEnrollmentAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + var confirmed = await _mfaAccountService.ConfirmEnrollmentAsync(user, Input.Code, cancellationToken); + StatusMessage = confirmed ? "MFA has been enabled." : "The authenticator code was invalid."; + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostRemoveAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + var removed = await _mfaAccountService.RemoveAuthenticatorAsync(user, Input.CurrentPassword, Input.Code, cancellationToken); + StatusMessage = removed ? "MFA has been removed." : "The password or authenticator code was invalid."; + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + private async Task GetUserAsync() => await _userManager.GetUserAsync(this.User); + + private async Task LoadStateAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + this.IsMfaEnabled = user is not null && await _userManager.GetTwoFactorEnabledAsync(user); + } + + public sealed class InputModel + { + public string Code { get; set; } = string.Empty; + + public string CurrentPassword { get; set; } = string.Empty; + } +} diff --git a/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml new file mode 100644 index 00000000..f55a00d8 --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml @@ -0,0 +1,46 @@ +@page +@model SecurityService.Pages.Account.ManageMfa.RecoveryCodesModel +@{ + ViewData["Title"] = "Recovery codes"; +} + +
+
+

Recovery codes

+

Use a recovery code if you cannot access your authenticator app.

+
+ + @if (string.IsNullOrWhiteSpace(Model.StatusMessage) == false) + { +
@Model.StatusMessage
+ } + +
+
+ @if (Model.RecoveryCodes.Count > 0) + { +

Save these codes now. They will not be displayed again.

+
+ @foreach (var code in Model.RecoveryCodes) + { + @code + } +
+ } + else + { +

Generating a new set revokes all previous recovery codes.

+ } + +
+
+ +
+
+ +
+ Back to MFA +
+
+
+
diff --git a/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs new file mode 100644 index 00000000..efe518fc --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.Entities; + +namespace SecurityService.Pages.Account.ManageMfa; + +[Authorize] +public sealed class RecoveryCodesModel : PageModel +{ + private readonly UserManager _userManager; + private readonly MfaAccountService _mfaAccountService; + + public RecoveryCodesModel(UserManager userManager, MfaAccountService mfaAccountService) + { + _userManager = userManager; + _mfaAccountService = mfaAccountService; + } + + public IReadOnlyList RecoveryCodes { get; private set; } = Array.Empty(); + + public string StatusMessage { get; private set; } = string.Empty; + + public async Task OnPostGenerateAsync(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(this.User); + if (user is null) + { + return Challenge(); + } + + RecoveryCodes = await _mfaAccountService.GenerateRecoveryCodesAsync(user, 10, cancellationToken); + StatusMessage = "Store these recovery codes securely. They will not be shown again."; + return Page(); + } + + public async Task OnPostRevokeAsync(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(this.User); + if (user is null) + { + return Challenge(); + } + + await _mfaAccountService.RevokeRecoveryCodesAsync(user, cancellationToken); + StatusMessage = "Recovery codes have been revoked."; + return Page(); + } +} diff --git a/SecurityService/Pages/Shared/_Nav.cshtml b/SecurityService/Pages/Shared/_Nav.cshtml index c6350038..e91770aa 100644 --- a/SecurityService/Pages/Shared/_Nav.cshtml +++ b/SecurityService/Pages/Shared/_Nav.cshtml @@ -18,6 +18,7 @@