From c29f7ef7dd13f33b746670c6c4136ac7e618917a Mon Sep 17 00:00:00 2001 From: "HOME-LAPTOP\\kshkulev" Date: Mon, 6 Oct 2025 18:45:27 +0900 Subject: [PATCH] Add identity first iteration --- Hcs.WebApp/Components/Layout/MainLayout.razor | 52 +++- Hcs.WebApp/Components/Pages/Test/Export.razor | 13 + Hcs.WebApp/Components/_Imports.razor | 8 +- Hcs.WebApp/Data/AppIdentityDbContext.cs | 7 + Hcs.WebApp/Data/AppUser.cs | 6 + ...000000000_CreateIdentitySchema.Designer.cs | 274 ++++++++++++++++++ .../00000000000000_CreateIdentitySchema.cs | 222 ++++++++++++++ .../AppIdentityDbContextModelSnapshot.cs | 269 +++++++++++++++++ Hcs.WebApp/Hcs.WebApp.csproj | 9 + ...omponentsEndpointRouteBuilderExtensions.cs | 27 ++ .../Identity/IdentityRedirectManager.cs | 62 ++++ ...RevalidatingAuthenticationStateProvider.cs | 45 +++ Hcs.WebApp/Identity/IdentityUserAccessor.cs | 18 ++ Hcs.WebApp/Program.cs | 38 ++- Hcs.WebApp/appsettings.Development.json | 3 + Hcs.WebApp/appsettings.json | 3 + 16 files changed, 1046 insertions(+), 10 deletions(-) create mode 100644 Hcs.WebApp/Data/AppIdentityDbContext.cs create mode 100644 Hcs.WebApp/Data/AppUser.cs create mode 100644 Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs create mode 100644 Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.cs create mode 100644 Hcs.WebApp/Data/Migrations/AppIdentityDbContextModelSnapshot.cs create mode 100644 Hcs.WebApp/Identity/IdentityComponentsEndpointRouteBuilderExtensions.cs create mode 100644 Hcs.WebApp/Identity/IdentityRedirectManager.cs create mode 100644 Hcs.WebApp/Identity/IdentityRevalidatingAuthenticationStateProvider.cs create mode 100644 Hcs.WebApp/Identity/IdentityUserAccessor.cs diff --git a/Hcs.WebApp/Components/Layout/MainLayout.razor b/Hcs.WebApp/Components/Layout/MainLayout.razor index ecdfaa9..aae7479 100644 --- a/Hcs.WebApp/Components/Layout/MainLayout.razor +++ b/Hcs.WebApp/Components/Layout/MainLayout.razor @@ -1,5 +1,7 @@ @inherits LayoutComponentBase +@implements IDisposable + @inject NavigationManager NavigationManager @@ -12,12 +14,30 @@ - - - - - - + + + + + + + + + + + + +
+ + + +
+ + + + +
+
+
@Body @@ -32,4 +52,24 @@ @code { bool sidebarExpanded = true; + string? currentUrl; + + protected override void OnInitialized() + { + currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + + NavigationManager.LocationChanged += OnLocationChanged; + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + currentUrl = NavigationManager.ToBaseRelativePath(e.Location); + + StateHasChanged(); + } } diff --git a/Hcs.WebApp/Components/Pages/Test/Export.razor b/Hcs.WebApp/Components/Pages/Test/Export.razor index 88efafa..4368bc1 100644 --- a/Hcs.WebApp/Components/Pages/Test/Export.razor +++ b/Hcs.WebApp/Components/Pages/Test/Export.razor @@ -1,5 +1,6 @@ @page "/test/export" +@using Microsoft.AspNetCore.Authorization @using Hcs.Broker @using Hcs.Broker.Logger @using Hcs.Broker.MessageCapturer @@ -8,6 +9,10 @@ @using Hcs.WebApp.Config @using Hcs.WebApp.Utils +@implements IDisposable + +@attribute [Authorize] + @inject NavigationManager NavigationManager @inject IConfiguration Configuration @@ -82,6 +87,14 @@ client.SetSigningCertificate(brokerConfig.CertificateSerialNumber); } + public void Dispose() + { + if (messageCapturer != null) + { + messageCapturer.OnFileWritten -= OnFileWritten; + } + } + void OnLog(string log) { console.Log(log); diff --git a/Hcs.WebApp/Components/_Imports.razor b/Hcs.WebApp/Components/_Imports.razor index b276bdf..1d7cf24 100644 --- a/Hcs.WebApp/Components/_Imports.razor +++ b/Hcs.WebApp/Components/_Imports.razor @@ -1,12 +1,14 @@ @using System.Net.Http @using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Hcs.WebApp -@using Hcs.WebApp.Components @using Radzen @using Radzen.Blazor +@using Hcs.WebApp +@using Hcs.WebApp.Components + +@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/Hcs.WebApp/Data/AppIdentityDbContext.cs b/Hcs.WebApp/Data/AppIdentityDbContext.cs new file mode 100644 index 0000000..3e760cd --- /dev/null +++ b/Hcs.WebApp/Data/AppIdentityDbContext.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Hcs.WebApp.Data +{ + internal class AppIdentityDbContext(DbContextOptions options) : IdentityDbContext(options) { } +} diff --git a/Hcs.WebApp/Data/AppUser.cs b/Hcs.WebApp/Data/AppUser.cs new file mode 100644 index 0000000..21b6d93 --- /dev/null +++ b/Hcs.WebApp/Data/AppUser.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Identity; + +namespace Hcs.WebApp.Data +{ + internal class AppUser : IdentityUser { } +} diff --git a/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs new file mode 100644 index 0000000..8bfd051 --- /dev/null +++ b/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -0,0 +1,274 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable +namespace Hcs.WebApp.Data.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + [Migration("00000000000000_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Hcs.WebApp.Data.AppUser", 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("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + 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("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("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("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("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hcs.WebApp.Data.AppUser", 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("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.cs new file mode 100644 index 0000000..8cb9094 --- /dev/null +++ b/Hcs.WebApp/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -0,0 +1,222 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable +namespace Hcs.WebApp.Data.Migrations +{ + /// + internal partial class CreateIdentitySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/Hcs.WebApp/Data/Migrations/AppIdentityDbContextModelSnapshot.cs b/Hcs.WebApp/Data/Migrations/AppIdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..2fa491b --- /dev/null +++ b/Hcs.WebApp/Data/Migrations/AppIdentityDbContextModelSnapshot.cs @@ -0,0 +1,269 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Hcs.WebApp.Data.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + internal class AppIdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Hcs.WebApp.Data.AppUser", 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("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + 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("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("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("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("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hcs.WebApp.Data.AppUser", 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("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hcs.WebApp.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Hcs.WebApp/Hcs.WebApp.csproj b/Hcs.WebApp/Hcs.WebApp.csproj index 1315484..65cd85e 100644 --- a/Hcs.WebApp/Hcs.WebApp.csproj +++ b/Hcs.WebApp/Hcs.WebApp.csproj @@ -7,6 +7,15 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Hcs.WebApp/Identity/IdentityComponentsEndpointRouteBuilderExtensions.cs b/Hcs.WebApp/Identity/IdentityComponentsEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..58cc656 --- /dev/null +++ b/Hcs.WebApp/Identity/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Hcs.WebApp.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Hcs.WebApp.Identity +{ + internal static class IdentityComponentsEndpointRouteBuilderExtensions + { + public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var accountGroup = endpoints.MapGroup("/account"); + accountGroup.MapPost("/logout", async ( + ClaimsPrincipal user, + SignInManager signInManager, + [FromForm] string returnUrl) => + { + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/{returnUrl}"); + }); + + return accountGroup; + } + } +} diff --git a/Hcs.WebApp/Identity/IdentityRedirectManager.cs b/Hcs.WebApp/Identity/IdentityRedirectManager.cs new file mode 100644 index 0000000..ef2bb53 --- /dev/null +++ b/Hcs.WebApp/Identity/IdentityRedirectManager.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Components; +using System.Diagnostics.CodeAnalysis; + +namespace Hcs.WebApp.Identity +{ + internal sealed class IdentityRedirectManager(NavigationManager navigationManager) + { + public const string STATUS_COOKIE_NAME = "Identity.StatusMessage"; + + private static readonly CookieBuilder statusCookieBuilder = new() + { + SameSite = SameSiteMode.Strict, + HttpOnly = true, + IsEssential = true, + MaxAge = TimeSpan.FromSeconds(5), + }; + + private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); + + [DoesNotReturn] + public void RedirectTo(string? uri) + { + uri ??= ""; + + if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) + { + uri = navigationManager.ToBaseRelativePath(uri); + } + + navigationManager.NavigateTo(uri); + + throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} может быть использован только при статичном рендеринге"); + } + + [DoesNotReturn] + public void RedirectTo(string uri, Dictionary queryParameters) + { + var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); + var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); + RedirectTo(newUri); + } + + [DoesNotReturn] + public void RedirectToWithStatus(string uri, string message, HttpContext context) + { + context.Response.Cookies.Append(STATUS_COOKIE_NAME, message, statusCookieBuilder.Build(context)); + RedirectTo(uri); + } + + [DoesNotReturn] + public void RedirectToCurrentPage() + { + RedirectTo(CurrentPath); + } + + [DoesNotReturn] + public void RedirectToCurrentPageWithStatus(string message, HttpContext context) + { + RedirectToWithStatus(CurrentPath, message, context); + } + } +} diff --git a/Hcs.WebApp/Identity/IdentityRevalidatingAuthenticationStateProvider.cs b/Hcs.WebApp/Identity/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..dd98fe2 --- /dev/null +++ b/Hcs.WebApp/Identity/IdentityRevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,45 @@ +using Hcs.WebApp.Data; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Hcs.WebApp.Identity +{ + internal sealed class IdentityRevalidatingAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + IOptions options) : RevalidatingServerAuthenticationStateProvider(loggerFactory) + { + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return false; + } + else if (!userManager.SupportsUserSecurityStamp) + { + return true; + } + else + { + var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); + var userStamp = await userManager.GetSecurityStampAsync(user); + return principalStamp == userStamp; + } + } + } +} diff --git a/Hcs.WebApp/Identity/IdentityUserAccessor.cs b/Hcs.WebApp/Identity/IdentityUserAccessor.cs new file mode 100644 index 0000000..0446880 --- /dev/null +++ b/Hcs.WebApp/Identity/IdentityUserAccessor.cs @@ -0,0 +1,18 @@ +using Hcs.WebApp.Data; +using Microsoft.AspNetCore.Identity; + +namespace Hcs.WebApp.Identity +{ + internal sealed class IdentityUserAccessor(UserManager userManager, IdentityRedirectManager redirectManager) + { + public async Task GetRequiredUserAsync(HttpContext context) + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + { + redirectManager.RedirectToWithStatus("account/invalid_user", $"Ошибка: Не удалось загрузить пользователя с идентификатором '{userManager.GetUserId(context.User)}'", context); + } + return user; + } + } +} diff --git a/Hcs.WebApp/Program.cs b/Hcs.WebApp/Program.cs index 7e956cc..ee6889a 100644 --- a/Hcs.WebApp/Program.cs +++ b/Hcs.WebApp/Program.cs @@ -1,15 +1,49 @@ using Hcs.WebApp.Components; +using Hcs.WebApp.Data; +using Hcs.WebApp.Identity; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Radzen; var builder = WebApplication.CreateBuilder(args); + builder.Services .AddRazorComponents() .AddInteractiveServerComponents(); builder.Services.AddRadzenComponents(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + +var connectionString = builder.Configuration.GetConnectionString("IdentityConnection") ?? throw new InvalidOperationException(" 'IdentityConnection'"); +builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + +builder.Services + .AddIdentityCore() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + var app = builder.Build(); -if (!app.Environment.IsDevelopment()) + +if (app.Environment.IsDevelopment()) +{ + app.UseMigrationsEndPoint(); +} +else { app.UseExceptionHandler("/error", createScopeForErrors: true); app.UseHsts(); @@ -24,4 +58,6 @@ app .MapRazorComponents() .AddInteractiveServerRenderMode(); +app.MapAdditionalIdentityEndpoints(); + app.Run(); diff --git a/Hcs.WebApp/appsettings.Development.json b/Hcs.WebApp/appsettings.Development.json index 35f62ad..1f13e91 100644 --- a/Hcs.WebApp/appsettings.Development.json +++ b/Hcs.WebApp/appsettings.Development.json @@ -11,5 +11,8 @@ "OrgPPAGUID": "ccd7fa02-a2bf-428a-984b-faef69ae0eb2", "ExecutorGUID": "ccd7fa02-a2bf-428a-984b-faef69ae0eb2", "CertificateSerialNumber": "0636D2330032B3C38A4A26D765C787C248" + }, + "ConnectionStrings": { + "AuthConnection": "Server=localhost\\SQLEXPRESS;Database=hcs_web_app_identity;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=Yes" } } diff --git a/Hcs.WebApp/appsettings.json b/Hcs.WebApp/appsettings.json index 927e57b..ff571cc 100644 --- a/Hcs.WebApp/appsettings.json +++ b/Hcs.WebApp/appsettings.json @@ -11,5 +11,8 @@ "OrgPPAGUID": "ccd7fa02-a2bf-428a-984b-faef69ae0eb2", "ExecutorGUID": "ccd7fa02-a2bf-428a-984b-faef69ae0eb2", "CertificateSerialNumber": "0636D2330032B3C38A4A26D765C787C248" + }, + "ConnectionStrings": { + "IdentityConnection": "Server=localhost\\SQLEXPRESS;Database=hcs_web_app_identity;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=Yes" } }