From 9da7ed0e086bdc5c274513e793977785c659811a Mon Sep 17 00:00:00 2001 From: Mohab Date: Sat, 18 Jul 2026 22:07:02 +0300 Subject: [PATCH 1/5] add refresh token rotation with secure cookie sessions --- README.md | 16 +- src/Analysim.Core/Entities/RefreshToken.cs | 43 + src/Analysim.Core/Entities/User.cs | 2 + src/Analysim.Core/Helper/JwtSettings.cs | 1 + .../Data/ApplicationDbContext.cs | 18 + .../ApplicationDbContextModelSnapshot.cs | 64 + ...0260717132040_AddRefreshTokens.Designer.cs | 1182 +++++++++++++++++ .../20260717132040_AddRefreshTokens.cs | 64 + .../ClientApp/src/app/app.module.ts | 8 +- .../ClientApp/src/app/guards/admin.guard.ts | 4 +- .../src/app/guards/auth-guard.service.ts | 4 +- .../app/interceptors/auth.interceptor.spec.ts | 60 + .../src/app/interceptors/auth.interceptor.ts | 101 ++ .../src/app/login/login.component.ts | 1 - .../src/app/services/account.service.ts | 193 +-- .../src/app/services/project.service.ts | 239 +--- .../Controllers/AccountController.cs | 225 +++- .../Extensions/ServiceExtensions.cs | 31 +- src/Analysim.Web/Services/AuthTokenResult.cs | 18 + src/Analysim.Web/Services/AuthTokenService.cs | 215 +++ src/Analysim.Web/Startup.cs | 40 + 21 files changed, 2191 insertions(+), 338 deletions(-) create mode 100644 src/Analysim.Core/Entities/RefreshToken.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.cs create mode 100644 src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.ts create mode 100644 src/Analysim.Web/Services/AuthTokenResult.cs create mode 100644 src/Analysim.Web/Services/AuthTokenService.cs diff --git a/README.md b/README.md index 62a1a7b0..d3574ce3 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ The structure of the files are as follows (`XXX` means redacted): "JwtSettings": { "Issuer": "AnalySim", "Secret": "XXX", - "ExpireTime": 60, - "Audience": "https://www.analysim.tech" + "ExpireTime": 15, + "Audience": "https://www.analysim.tech", + "RefreshTokenExpireDays": 14 }, "UserQuota": 100000000, "registrationCodes": [ "123" ], @@ -94,6 +95,17 @@ The structure of the files are as follows (`XXX` means redacted): ``` +#### Authentication tokens + +AnalySim uses short-lived JWT access tokens and rotating refresh tokens. The API sends both as `HttpOnly`, `Secure`, `SameSite=Lax` cookies, and the Angular client sends requests with credentials instead of storing JWTs in `localStorage`. + +- `JwtSettings:ExpireTime` controls the access-token lifetime in minutes. Keep this short, for example `15`. +- `JwtSettings:RefreshTokenExpireDays` controls the refresh-token lifetime in days. +- Refresh tokens are stored in the database as SHA-256 hashes in the `RefreshTokens` table. Raw refresh tokens are only sent to the browser as cookies. +- Refresh tokens rotate on `/api/account/refresh`; reuse of a revoked rotated token revokes the remaining token family. +- Logout, password changes, and account deletion revoke refresh tokens. +- Unsafe authenticated API requests require the `X-XSRF-TOKEN` header matching the `XSRF-TOKEN` cookie. + #### Adding admin users Admin access in Analysim is controlled through the AdminUsers section of the `appsettings.json` and `appsettings.Development.json`. Each entry in the list corresponds to the username of a registered Analysim user. Admin users will see an Admin link in the navigation bar and can access the /admin section of the platform. To add or remove admin privileges, simply update this list and restart the server. diff --git a/src/Analysim.Core/Entities/RefreshToken.cs b/src/Analysim.Core/Entities/RefreshToken.cs new file mode 100644 index 00000000..43c334d2 --- /dev/null +++ b/src/Analysim.Core/Entities/RefreshToken.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class RefreshToken + { + public int RefreshTokenID { get; set; } + + public int UserID { get; set; } + + public User User { get; set; } + + [Required] + public string TokenHash { get; set; } + + public Guid TokenFamilyID { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime ExpiresAt { get; set; } + + public DateTime? RevokedAt { get; set; } + + public string ReplacedByTokenHash { get; set; } + + public string RevokedReason { get; set; } + + public string CreatedByIp { get; set; } + + public string RevokedByIp { get; set; } + + [NotMapped] + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; + + [NotMapped] + public bool IsRevoked => RevokedAt.HasValue; + + [NotMapped] + public bool IsActive => !IsRevoked && !IsExpired; + } +} diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 986fc539..fde6c0f2 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -30,6 +30,8 @@ public class User : IdentityUser // Logs public ICollection ProjectLogs { get; set; } = new List(); + public ICollection RefreshTokens { get; set; } = new List(); + public string RegistrationSurvey {get; set;} } diff --git a/src/Analysim.Core/Helper/JwtSettings.cs b/src/Analysim.Core/Helper/JwtSettings.cs index bd271d8b..f20e2325 100644 --- a/src/Analysim.Core/Helper/JwtSettings.cs +++ b/src/Analysim.Core/Helper/JwtSettings.cs @@ -11,5 +11,6 @@ public class JwtSettings public string Secret { get; set; } public string ExpireTime { get; set; } public string Audience { get; set; } + public int RefreshTokenExpireDays { get; set; } = 14; } } diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 0e8812a1..d6378698 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -8,6 +8,8 @@ namespace Infrastructure.Data { public class ApplicationDbContext : IdentityDbContext, int> { + public DbSet RefreshTokens { get; set; } + public ApplicationDbContext(DbContextOptions options) : base(options) { @@ -24,6 +26,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasKey(uu => new { uu.UserID, uu.FollowerID }); modelBuilder.Entity().HasKey(nc => new { nc.NotebookID, nc.Version }); + modelBuilder.Entity() + .HasIndex(rt => rt.TokenHash) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(rt => rt.UserID); + + modelBuilder.Entity() + .HasIndex(rt => rt.TokenFamilyID); + // Many To Many Relationship (ProjectUser -> User) modelBuilder.Entity() .HasOne(i => i.User) @@ -66,6 +78,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(u => u.User) .HasForeignKey(u => u.UserID); + modelBuilder.Entity() + .HasMany(u => u.RefreshTokens) + .WithOne(rt => rt.User) + .HasForeignKey(rt => rt.UserID) + .OnDelete(DeleteBehavior.Cascade); + // One To Many Relationship (Project -> Blob) modelBuilder.Entity() .HasMany(p => p.BlobFiles) diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 7989d6d6..ccc42ecf 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -489,6 +489,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Publications"); }); + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.Property("RefreshTokenID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RefreshTokenID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("RevokedReason") + .HasColumnType("text"); + + b.Property("TokenFamilyID") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("RefreshTokenID"); + + b.HasIndex("TokenFamilyID"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserID"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("Core.Entities.Tag", b => { b.Property("TagID") @@ -974,6 +1025,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Project"); }); + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.UserUser", b => { b.HasOne("Core.Entities.User", "Follower") @@ -1109,6 +1171,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); b.Navigation("ProjectUsers"); + + b.Navigation("RefreshTokens"); }); #pragma warning restore 612, 618 } diff --git a/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.Designer.cs new file mode 100644 index 00000000..9de0a676 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.Designer.cs @@ -0,0 +1,1182 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260717132040_AddRefreshTokens")] + partial class AddRefreshTokens + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPendingReview") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ProjectLogID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ProjectLogID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.Property("LogID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LogID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookVersion") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("LogID"); + + b.HasIndex("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ReferencedNotebookID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectLogs"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Publication", b => + { + b.Property("PublicationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PublicationID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Doi") + .HasColumnType("text"); + + b.Property("Issue") + .HasColumnType("text"); + + b.Property("Journal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Pages") + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("SourceAuthor") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("Volume") + .HasColumnType("text"); + + b.Property("Year") + .IsRequired() + .HasColumnType("integer"); + + b.HasKey("PublicationID"); + + b.HasIndex("ProjectID"); + + b.ToTable("Publications"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.Property("RefreshTokenID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RefreshTokenID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("RevokedReason") + .HasColumnType("text"); + + b.Property("TokenFamilyID") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("RefreshTokenID"); + + b.HasIndex("TokenFamilyID"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserID"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("ReceiveCommentReplyEmails") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "d7c6278e-3f78-4db6-8832-aa41ea9fd62c", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "7383e31e-a884-4cd6-82f7-a6287b4e30a5", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "398ef1f2-6b82-45f2-9601-ed2129f197e9", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.ProjectLog", "ProjectLog") + .WithMany("Comments") + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("ProjectLog"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany() + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectLogs") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Notebook", "ReferencedNotebook") + .WithMany() + .HasForeignKey("ReferencedNotebookID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectLogs") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BlobFile"); + + b.Navigation("Project"); + + b.Navigation("ReferencedNotebook"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Publication", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Publications") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + 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("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", 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("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectLogs"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + + b.Navigation("Publications"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.Navigation("Comments"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectLogs"); + + b.Navigation("ProjectUsers"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.cs b/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.cs new file mode 100644 index 00000000..d1251a26 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260717132040_AddRefreshTokens.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddRefreshTokens : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + RefreshTokenID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserID = table.Column(type: "integer", nullable: false), + TokenHash = table.Column(type: "text", nullable: false), + TokenFamilyID = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByTokenHash = table.Column(type: "text", nullable: true), + RevokedReason = table.Column(type: "text", nullable: true), + CreatedByIp = table.Column(type: "text", nullable: true), + RevokedByIp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.RefreshTokenID); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenFamilyID", + table: "RefreshTokens", + column: "TokenFamilyID"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserID", + table: "RefreshTokens", + column: "UserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/app.module.ts b/src/Analysim.Web/ClientApp/src/app/app.module.ts index 9d006cc0..97abc6c6 100644 --- a/src/Analysim.Web/ClientApp/src/app/app.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/app.module.ts @@ -1,6 +1,6 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; -import { HttpClientModule } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from '@angular/common/http'; import { AppRoutingModule } from './app-routing.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -32,6 +32,7 @@ import { EmailConfirmationComponent } from './email-confirmation/email-confirmat import { EmailForgotPassComponent } from './email-confirmation/email-forgot-pass/email-forgot-pass.component'; import { ResetPasswordComponent } from './email-confirmation/reset-password/reset-password.component'; import { EmailResendVerificationComponent } from './email-confirmation/email-resend-verification/email-resend-verification.component'; +import { AuthInterceptor } from './interceptors/auth.interceptor'; @NgModule({ declarations: [ @@ -63,6 +64,10 @@ import { EmailResendVerificationComponent } from './email-confirmation/email-res BrowserModule, AppRoutingModule, HttpClientModule, + HttpClientXsrfModule.withOptions({ + cookieName: 'XSRF-TOKEN', + headerName: 'X-XSRF-TOKEN', + }), FormsModule, ReactiveFormsModule, BrowserAnimationsModule, @@ -71,6 +76,7 @@ import { EmailResendVerificationComponent } from './email-confirmation/email-res ModalModule.forRoot(), ], providers: [ + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, ], bootstrap: [AppComponent] }) diff --git a/src/Analysim.Web/ClientApp/src/app/guards/admin.guard.ts b/src/Analysim.Web/ClientApp/src/app/guards/admin.guard.ts index b0ee1d9f..3739065b 100644 --- a/src/Analysim.Web/ClientApp/src/app/guards/admin.guard.ts +++ b/src/Analysim.Web/ClientApp/src/app/guards/admin.guard.ts @@ -16,9 +16,7 @@ export class AdminGuard implements CanActivate { ) {} public async canActivate(): Promise { - const isLoggedIn = await firstValueFrom( - this.account.isLoggedIn.pipe(take(1)) - ); + const isLoggedIn = await firstValueFrom(this.account.ensureAuthenticated()); if (!isLoggedIn) { this.router.navigate(['/login']); return false; diff --git a/src/Analysim.Web/ClientApp/src/app/guards/auth-guard.service.ts b/src/Analysim.Web/ClientApp/src/app/guards/auth-guard.service.ts index 0569a09d..b0f3cb63 100644 --- a/src/Analysim.Web/ClientApp/src/app/guards/auth-guard.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/guards/auth-guard.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { CanActivate, RouterStateSnapshot, Router, ActivatedRouteSnapshot } from '@angular/router'; import { AccountService } from '../services/account.service'; -import { take, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; @Injectable({ @@ -12,7 +12,7 @@ export class AuthGuardService implements CanActivate { constructor(private acct : AccountService, private router: Router) { } canActivate(route : ActivatedRouteSnapshot, state : RouterStateSnapshot) : Observable { - return this.acct.isLoggedIn.pipe(take(1), map((loginStatus : boolean) => + return this.acct.ensureAuthenticated().pipe(map((loginStatus : boolean) => { const destination : string = state.url diff --git a/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.spec.ts b/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.spec.ts new file mode 100644 index 00000000..c3b9cbc1 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.spec.ts @@ -0,0 +1,60 @@ +import { HTTP_INTERCEPTORS, HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AuthInterceptor } from './auth.interceptor'; +import { AccountService } from '../services/account.service'; + +describe('AuthInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let accountService: jasmine.SpyObj; + + beforeEach(() => { + accountService = jasmine.createSpyObj( + 'AccountService', + ['refreshSession', 'clearAuthenticationState'] + ); + accountService.refreshSession.and.returnValue(of({ result: { id: 1 } })); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('sends credentials and strips stale bearer headers', () => { + http.get('/api/projects', { + headers: new HttpHeaders({ Authorization: 'Bearer stale-token' }), + }).subscribe(); + + const request = httpMock.expectOne('/api/projects'); + expect(request.request.withCredentials).toBeTrue(); + expect(request.request.headers.has('Authorization')).toBeFalse(); + + request.flush({}); + }); + + it('refreshes once on 401 and retries the original request', () => { + http.get('/api/projects').subscribe(); + + const firstRequest = httpMock.expectOne('/api/projects'); + firstRequest.flush({}, { status: 401, statusText: 'Unauthorized' }); + + expect(accountService.refreshSession).toHaveBeenCalledTimes(1); + + const retryRequest = httpMock.expectOne('/api/projects'); + expect(retryRequest.request.headers.get('X-Auth-Retry')).toBe('1'); + retryRequest.flush({ result: [] }); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.ts b/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.ts new file mode 100644 index 00000000..7fbff7d3 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interceptors/auth.interceptor.ts @@ -0,0 +1,101 @@ +import { Injectable, Injector } from '@angular/core'; +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; +import { catchError, filter, switchMap, take } from 'rxjs/operators'; +import { AccountService } from '../services/account.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private isRefreshing = false; + private refreshResult = new BehaviorSubject(null); + + constructor(private injector: Injector) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const authRequest = this.prepareRequest(request); + + return next.handle(authRequest).pipe( + catchError((error: HttpErrorResponse) => { + if (!this.shouldRefresh(authRequest, error)) { + return throwError(error); + } + + return this.refreshAndRetry(authRequest, next); + }) + ); + } + + private prepareRequest(request: HttpRequest): HttpRequest { + let headers = request.headers; + const authorization = headers.get('Authorization'); + + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + headers = headers.delete('Authorization'); + } + + return request.clone({ + headers, + withCredentials: true, + }); + } + + private shouldRefresh(request: HttpRequest, error: HttpErrorResponse): boolean { + if (error.status !== 401 || request.headers.has('X-Auth-Retry')) { + return false; + } + + const url = request.url.toLowerCase(); + return !url.includes('/api/account/login') + && !url.includes('/api/account/refresh') + && !url.includes('/api/account/logout'); + } + + private refreshAndRetry(request: HttpRequest, next: HttpHandler): Observable> { + const accountService = this.injector.get(AccountService); + + if (this.isRefreshing) { + return this.refreshResult.pipe( + filter(refreshed => refreshed !== null), + take(1), + switchMap(refreshed => { + if (!refreshed) { + return throwError(() => new Error('Authentication renewal failed.')); + } + + const retryRequest = this.prepareRequest( + request.clone({ headers: request.headers.set('X-Auth-Retry', '1') }) + ); + + return next.handle(retryRequest); + }) + ); + } + + this.isRefreshing = true; + this.refreshResult.next(null); + + return accountService.refreshSession().pipe( + switchMap(() => { + this.isRefreshing = false; + this.refreshResult.next(true); + const retryRequest = this.prepareRequest( + request.clone({ headers: request.headers.set('X-Auth-Retry', '1') }) + ); + + return next.handle(retryRequest); + }), + catchError(error => { + this.isRefreshing = false; + this.refreshResult.next(false); + accountService.clearAuthenticationState(); + return throwError(error); + }) + ); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/login/login.component.ts b/src/Analysim.Web/ClientApp/src/app/login/login.component.ts index 06349a09..fa894476 100644 --- a/src/Analysim.Web/ClientApp/src/app/login/login.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/login/login.component.ts @@ -72,7 +72,6 @@ export class LoginComponent implements OnInit { this.acct.login(userLogin.username, userLogin.password).subscribe( result => { - let token = (result).token; this.invalidLogin = false; this.loginForm.reset(); this.router.navigateByUrl(this.returnUrl); diff --git a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts index 4bbc27c6..618705d3 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts @@ -1,9 +1,8 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { BehaviorSubject, Observable, empty, throwError } from 'rxjs'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { Router } from '@angular/router'; -import jwt_decode from "jwt-decode"; import { User } from '../interfaces/user'; import { UserUser } from '../interfaces/user-user'; import { NotificationService } from './notification.service'; @@ -15,7 +14,9 @@ import { BlobFile } from '../interfaces/blob-file'; }) export class AccountService { - constructor(private http: HttpClient, private router: Router, private notfi: NotificationService) { } + constructor(private http: HttpClient, private router: Router, private notfi: NotificationService) { + this.clearLegacyTokenStorage(); + } // Url to access Web API private baseUrl: string = '/api/account/' @@ -34,6 +35,9 @@ export class AccountService { private urlFollow: string = this.baseUrl + "follow" private urlRegister: string = this.baseUrl + "register" private urlLogin: string = this.baseUrl + "login" + private urlRefresh: string = this.baseUrl + "refresh" + private urlLogout: string = this.baseUrl + "logout" + private urlMe: string = this.baseUrl + "me" private urlUploadProfileImage: string = this.baseUrl + "uploadprofileimage" // Post @@ -57,7 +61,7 @@ export class AccountService { //User properties private loginStatus = new BehaviorSubject(this.checkLoginStatus()) private user = new BehaviorSubject(null) - private userID = new BehaviorSubject(parseInt(localStorage.getItem('userID'))) + private userID = new BehaviorSubject(this.getStoredUserID()) getUserByID(userID: number): Observable { return this.http.get(this.urlGetUserByID + userID) @@ -174,10 +178,7 @@ export class AccountService { body.append('userID', userID.toString()) body.append('followerID', followerID.toString()) - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - - return this.http.post(this.urlFollow, body, {headers}) + return this.http.post(this.urlFollow, body) .pipe( map(body => { console.log(body.message) @@ -218,14 +219,8 @@ export class AccountService { return this.http.post(this.urlLogin, body) .pipe( map(body => { - if (body && body.token) { - this.loginStatus.next(true) - this.user.next(body.result) - this.userID = new BehaviorSubject(parseInt(body.result.id)) - localStorage.setItem('loginStatus', '1') - localStorage.setItem('jwt', body.token) - localStorage.setItem('userID', body.result.id) - localStorage.setItem('expiration', body.expiration) + if (body && body.result) { + this.setAuthenticatedUser(body.result) } return body }), @@ -314,9 +309,8 @@ export class AccountService { let body = new FormData() body.append('file', file) body.append('userID', userID.toString()) - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - return this.http.post(this.urlUploadProfileImage, body, {headers}).pipe( + return this.http.post(this.urlUploadProfileImage, body).pipe( map(body => { console.log(body.message) return body.result @@ -331,10 +325,8 @@ export class AccountService { updateUser(bio: string, userID: number): Observable { let body = new FormData() body.append('bio', bio) - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.put(this.urlUpdateUser + userID, body, {headers}) + return this.http.put(this.urlUpdateUser + userID, body) .pipe( map(body => { console.log(body.message) @@ -352,12 +344,7 @@ export class AccountService { receiveCommentReplyEmails: receiveCommentReplyEmails }; - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlUpdateNotificationPreferences + userID, body, {headers}) + return this.http.put(this.urlUpdateNotificationPreferences + userID, body) .pipe( map(body => { console.log(body.message) @@ -371,9 +358,7 @@ export class AccountService { } unfollow(userID: number, followerID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlUnfollow + userID + '/' + followerID, {headers}) + return this.http.delete(this.urlUnfollow + userID + '/' + followerID) .pipe( map(body => { console.log(body.message) @@ -387,9 +372,7 @@ export class AccountService { } deleteProfileImage(blobFileID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteProfileImage + blobFileID, {headers}).pipe( + return this.http.delete(this.urlDeleteProfileImage + blobFileID).pipe( map(body => { console.log(body.message) return body.result @@ -402,9 +385,7 @@ export class AccountService { } deleteUser(userID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteUser + userID, {headers}).pipe( + return this.http.delete(this.urlDeleteUser + userID).pipe( map(body => { console.log(body.message) return body.message @@ -417,58 +398,52 @@ export class AccountService { } checkLoginStatus(): boolean { - // Get Login Cookie - var loginCookie = localStorage.getItem('loginStatus'); - - // Check Login Cookie - // 0 = Not Logged In - // 1 = Logged In - if (loginCookie == "1") { - // Return False If Null - if (localStorage.getItem('jwt') === null || localStorage.getItem('jwt') === undefined) { - return false; - } - - // Get and Decode the Token - const token = localStorage.getItem('jwt'); - const decoded: any = jwt_decode(token) - - // Check if the cookie is valid - if (decoded.exp === undefined) { - return false; - } - - // Get Current Time - const date = new Date(0) - - // Convert Expiration to UTC - let tokenExpDate = date.setUTCSeconds(decoded.exp) - - // Compare Expiration time with current time - if (tokenExpDate.valueOf() > new Date().valueOf()) { - return true; - } - - // Return False Since Token Expire - this.user = new BehaviorSubject(null) - return false; - } - this.user = new BehaviorSubject(null) - return false; + return this.getCookie('analysim.logged_in') === '1'; } logout() { - // Set Login Status to false - this.loginStatus.next(false) + this.http.post(this.urlLogout, {}) + .pipe(catchError(() => of(null))) + .subscribe(() => { + this.clearAuthenticationState() + this.router.navigate(['/login']) + }) + } - // Remove item from localStorage - localStorage.setItem('loginStatus', '0') - localStorage.removeItem('jwt') - localStorage.removeItem('expiration') - localStorage.removeItem('userID') + refreshSession(): Observable { + return this.http.post(this.urlRefresh, {}).pipe( + map(body => { + if (body && body.result) { + this.setAuthenticatedUser(body.result) + } + return body + }), + catchError(error => { + this.clearAuthenticationState() + return throwError(error) + }) + ) + } - // Navigate back to the login page - this.router.navigate(['/login']) + ensureAuthenticated(): Observable { + if (this.loginStatus.value) { + return of(true) + } + + return this.refreshSession().pipe( + map(() => true), + catchError(() => of(false)) + ) + } + + clearAuthenticationState(): void { + this.loginStatus.next(false) + this.user.next(null) + this.userID.next(null) + sessionStorage.removeItem('userID') + document.cookie = 'analysim.logged_in=; Max-Age=0; path=/; SameSite=Lax; Secure' + document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/; SameSite=Lax; Secure' + this.clearLegacyTokenStorage() } get isLoggedIn() { @@ -476,9 +451,9 @@ export class AccountService { } get currentUser() { - if (this.userID.value != null && this.user.value == null && this.loginStatus.value == true) { + if (this.user.value == null && this.loginStatus.value == true) { let promise = new Promise((resolve, reject) => { - this.getUserByID(this.userID.value) + this.getCurrentSessionUser() .toPromise() .then( body => { @@ -505,6 +480,52 @@ export class AccountService { return this.userID.asObservable() } + private getCurrentSessionUser(): Observable { + return this.http.get(this.urlMe).pipe( + map(body => { + if (body && body.result) { + this.setAuthenticatedUser(body.result) + return body.result + } + + return null + }), + catchError(error => { + this.clearAuthenticationState() + return throwError(error) + }) + ) + } + + private setAuthenticatedUser(user: User): void { + this.loginStatus.next(true) + this.user.next(user) + this.userID.next(user ? user.id : null) + if (user) { + sessionStorage.setItem('userID', user.id.toString()) + } + } + + private getStoredUserID(): number { + const storedUserID = sessionStorage.getItem('userID') + return storedUserID ? parseInt(storedUserID) : null + } + + private getCookie(name: string): string { + const cookie = document.cookie + .split('; ') + .find(row => row.startsWith(name + '=')) + + return cookie ? decodeURIComponent(cookie.split('=')[1]) : '' + } + + private clearLegacyTokenStorage(): void { + localStorage.removeItem('jwt') + localStorage.removeItem('expiration') + localStorage.removeItem('loginStatus') + localStorage.removeItem('userID') + } + // Error getProjectList(userID: number): Observable { return this.http.get(this.urlGetProjects + userID) diff --git a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts index 0d9011ef..4836a4b4 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Router } from '@angular/router'; import { map, catchError } from 'rxjs/operators'; import { Observable, empty, throwError } from 'rxjs'; @@ -275,10 +275,7 @@ export class ProjectService { body.append('visibility', visibility) body.append('description', description) body.append('route', currentUser.userName + "/" + projectName) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlCreateProject, body, {headers}) + return this.http.post(this.urlCreateProject, body) .pipe( map(body => { console.log(body.message) @@ -299,10 +296,7 @@ export class ProjectService { body.append('BlobFilesID', blobFilesID[i].toString()) } body.getAll('BlobFilesID') - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlForkProject, body, {headers}) + return this.http.post(this.urlForkProject, body) .pipe( map(body => { console.log(body.message) @@ -319,10 +313,7 @@ export class ProjectService { let body = new FormData() body.append('userID', userID.toString()) body.append('projectID', projectID.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlForkProjectWithoutBlob, body, {headers}) + return this.http.post(this.urlForkProjectWithoutBlob, body) .pipe( map(body => { console.log(body.message) @@ -343,10 +334,7 @@ export class ProjectService { body.append('userid', userID.toString()) body.append('userrole', userRole) body.append('isFollowing', isFollowing ? 'true' : 'false') - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlAddUser, body, {headers}) + return this.http.post(this.urlAddUser, body) .pipe( map(body => { console.log(body.message) @@ -360,9 +348,7 @@ export class ProjectService { } followProject(projectID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlFollowProject + projectID, null, { headers }).pipe( + return this.http.post(this.urlFollowProject + projectID, null).pipe( map(body => { console.log(body.message) return body.result @@ -375,9 +361,7 @@ export class ProjectService { } unfollowProject(projectID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlUnfollowProject + projectID, { headers }).pipe( + return this.http.delete(this.urlUnfollowProject + projectID).pipe( map(body => { console.log(body.message) return body.result @@ -393,10 +377,7 @@ export class ProjectService { let body = new FormData() body.append('projectid', projectID.toString()) body.append('tagname', tagName.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlAddTag, body, {headers}) + return this.http.post(this.urlAddTag, body) .pipe( map(body => { console.log(body.message) @@ -415,10 +396,7 @@ export class ProjectService { body.append('directory', directory) // body.append('userID', userID.toString()) body.append('projectID', projectID.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlUploadFile, body, {headers}).pipe( + return this.http.post(this.urlUploadFile, body).pipe( map(body => { console.log(body.result) return body.result @@ -436,10 +414,7 @@ export class ProjectService { body.append('NotebookName', notebook.name); body.append('ProjectID', notebook.projectID.toString()); body.append('directory', directory); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlUploadNotebook, body, {headers}).pipe( + return this.http.post(this.urlUploadNotebook, body).pipe( map(body => { console.log(body.result) return body.result @@ -457,10 +432,7 @@ export class ProjectService { body.append('NotebookName', notebook.name); body.append('ProjectID', notebook.projectID.toString()); body.append('directory', directory); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlUploadNotebookNewVersion, body, {headers}).pipe( + return this.http.post(this.urlUploadNotebookNewVersion, body).pipe( map(body => { console.log(body.message) return body.message @@ -480,10 +452,7 @@ export class ProjectService { body.append('Type', notebookURL.type); body.append('observableNotebookDatasets', JSON.stringify(notebookURL.datasets)); body.append('directory', directory); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlUploadExistingNotebook, body, {headers}).pipe( + return this.http.post(this.urlUploadExistingNotebook, body).pipe( map(body => { console.log(body.result) return body.result @@ -500,10 +469,7 @@ export class ProjectService { body.append('directory', directory) // body.append('userID', userID.toString()) body.append('projectID', projectID.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlCreateFolder, body, {headers}).pipe( + return this.http.post(this.urlCreateFolder, body).pipe( map(body => { console.log(body.message) return body.result @@ -520,10 +486,7 @@ export class ProjectService { body.append('directory', directory); body.append('folderName', folderName); body.append('projectID', projectID.toString()); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlCreateNotebookFolder, body, {headers}).pipe( + return this.http.post(this.urlCreateNotebookFolder, body).pipe( map(body => { console.log(body.message) return body.result @@ -540,10 +503,7 @@ export class ProjectService { body.append('notebookID', notebookID.toString()); body.append('datasetName', `${file.name}${file.extension}`); body.append('blobFileID', file.blobFileID.toString()); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.post(this.urlAddDatasetToNotebook, body, {headers}).pipe( + return this.http.post(this.urlAddDatasetToNotebook, body).pipe( map(body => { console.log(body.message) return body.result @@ -581,10 +541,7 @@ export class ProjectService { body.append('name', updateProject.name) body.append('visibility', updateProject.visibility) body.append('description', updateProject.description) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.put(this.urlUpdateProject + updateProject.projectID, body, {headers}) + return this.http.put(this.urlUpdateProject + updateProject.projectID, body) .pipe( map(body => { console.log(body.message) @@ -603,10 +560,7 @@ export class ProjectService { body.append('projectid', userRole.projectID.toString()) body.append('userrole', userRole.userRole) body.append('isFollowing', userRole.isFollowing ? 'true' : 'false') - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.put(this.urlupdateUser, body, {headers}) + return this.http.put(this.urlupdateUser, body) .pipe( map(body => { console.log(body.message) @@ -625,10 +579,7 @@ export class ProjectService { body.append('notebookID', notebookID.toString()); body.append('blobFileID', file.blobFileID.toString()); //console.log("calling delete dataset from notebook api"); - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.put(this.urlDeleteDatasetFromNotebook, body, {headers}).pipe( + return this.http.put(this.urlDeleteDatasetFromNotebook, body).pipe( map(result => { console.log("after calling delete dataset from notebook : ", result.message) return result.result @@ -643,10 +594,7 @@ export class ProjectService { deleteProject(projectID: number): Observable { let body = new FormData() // body.append('projectID', projectID.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteProject + projectID, {headers}) + return this.http.delete(this.urlDeleteProject + projectID) .pipe( map(body => { console.log(body.message) @@ -660,10 +608,8 @@ export class ProjectService { } removeUser(projectID: number, userID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlRemoveUser + projectID + '/' + userID, {headers}) + return this.http.delete(this.urlRemoveUser + projectID + '/' + userID) .pipe( map(body => { console.log(body.message) @@ -678,10 +624,7 @@ export class ProjectService { removeTag(projectID: number, tagID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - - return this.http.delete(this.urlRemoveTag + projectID + '/' + tagID, {headers}) + return this.http.delete(this.urlRemoveTag + projectID + '/' + tagID) .pipe( map(body => { console.log(body.message) @@ -695,9 +638,7 @@ export class ProjectService { } deleteFile(blobFileID: number, isMember: boolean): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteFile + blobFileID + '/' + isMember, {headers}) + return this.http.delete(this.urlDeleteFile + blobFileID + '/' + isMember) .pipe( map(body => { console.log(body.message) @@ -711,9 +652,7 @@ export class ProjectService { } deleteNotebook(notebookID: number, version: number, isMember: boolean): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteNotebook + notebookID + '/' + version + '/' + isMember, {headers}).pipe( + return this.http.delete(this.urlDeleteNotebook + notebookID + '/' + version + '/' + isMember).pipe( map(body => { console.log(body.message) return body.result @@ -880,13 +819,7 @@ export class ProjectService { body.append('content', content); if(parentID != null) body.append('parentCommentID', parentID.toString()); if(projectLogID != null) body.append('projectLogID', projectLogID.toString()); - - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlPostComment, body, {headers}).pipe( + return this.http.post(this.urlPostComment, body).pipe( map(body => { console.log(body.message); return body; @@ -901,13 +834,7 @@ export class ProjectService { updateComment(commentID: number, content: string): Observable { let body = new FormData(); body.append('content', content); - - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlUpdateComment + commentID, body, {headers}).pipe( + return this.http.put(this.urlUpdateComment + commentID, body).pipe( map(body => { console.log(body.message); return body; @@ -920,12 +847,7 @@ export class ProjectService { } deleteComment(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlDeleteComment + commentID, null, {headers}).pipe( + return this.http.put(this.urlDeleteComment + commentID, null).pipe( map(body => { console.log(body.message); return body; @@ -938,12 +860,7 @@ export class ProjectService { } likeComment(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlLikeComment + commentID, null, {headers}).pipe( + return this.http.post(this.urlLikeComment + commentID, null).pipe( map(body => { console.log(body.message); return body; @@ -956,12 +873,7 @@ export class ProjectService { } unlikeComment(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.delete(this.urlUnlikeComment + commentID, {headers}).pipe( + return this.http.delete(this.urlUnlikeComment + commentID).pipe( map(body => { console.log(body.message); return body; @@ -974,12 +886,7 @@ export class ProjectService { } reportComment(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlReportComment + commentID, null, {headers}).pipe( + return this.http.post(this.urlReportComment + commentID, null).pipe( map(body => { console.log(body.message); return body; @@ -992,12 +899,7 @@ export class ProjectService { } removeCommentReport(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.delete(this.urlRemoveCommentReport + commentID, {headers}).pipe( + return this.http.delete(this.urlRemoveCommentReport + commentID).pipe( map(body => { console.log(body.message); return body; @@ -1010,12 +912,7 @@ export class ProjectService { } removeallCommentReports(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.delete(this.urlRemoveAllCommentReports + commentID, {headers}).pipe( + return this.http.delete(this.urlRemoveAllCommentReports + commentID).pipe( map(body => { console.log(body.message); return body; @@ -1028,9 +925,7 @@ export class ProjectService { } getFlaggedProjectComments(): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.get(this.urlGetFlaggedProjectComments, {headers}).pipe( + return this.http.get(this.urlGetFlaggedProjectComments).pipe( map(body => { console.log(body.message); return body.result; @@ -1043,12 +938,7 @@ export class ProjectService { } deleteCommentAndReports(commentID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlDeleteCommentandReports + commentID, null, {headers}).pipe( + return this.http.post(this.urlDeleteCommentandReports + commentID, null).pipe( map(body => { console.log(body.message); return body; @@ -1075,12 +965,7 @@ export class ProjectService { } addPublication(formData: FormData): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlAddPublication, formData, {headers}).pipe( + return this.http.post(this.urlAddPublication, formData).pipe( map(body => { console.log(body.message); return body; @@ -1093,12 +978,7 @@ export class ProjectService { } deletePublication(publicationId: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.delete(this.urlDeletePublication + publicationId, {headers}).pipe( + return this.http.delete(this.urlDeletePublication + publicationId).pipe( map(body => { console.log(body.message); return body; @@ -1111,12 +991,7 @@ export class ProjectService { } updatePublication(formData: FormData, publicationId: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlUpdatePublication + publicationId, formData, {headers}).pipe( + return this.http.put(this.urlUpdatePublication + publicationId, formData).pipe( map(body => { console.log(body.message); return body; @@ -1143,12 +1018,7 @@ export class ProjectService { } addProjectLog(formData: FormData): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.post(this.urlAddProjectLog, formData, {headers}).pipe( + return this.http.post(this.urlAddProjectLog, formData).pipe( map(body => { console.log(body.message); return body; @@ -1161,12 +1031,7 @@ export class ProjectService { } updateProjectLog(formData: FormData, logId: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlUpdateProjectLog + logId, formData, {headers}).pipe( + return this.http.put(this.urlUpdateProjectLog + logId, formData).pipe( map(body => { console.log(body.message); return body; @@ -1193,12 +1058,7 @@ export class ProjectService { } deleteProjectLog(logID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlDeleteProjectLog + logID, null, {headers}).pipe( + return this.http.put(this.urlDeleteProjectLog + logID, null).pipe( map(body => { console.log(body.message); return body; @@ -1211,12 +1071,7 @@ export class ProjectService { } repostProjectLog(logID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.put(this.urlRepostProjectLog + logID, null, {headers}).pipe( + return this.http.put(this.urlRepostProjectLog + logID, null).pipe( map(body => { console.log(body.message); return body; @@ -1229,12 +1084,7 @@ export class ProjectService { } getExpiredProjectLogs(): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.get(this.urlGetExpiredProjectLogs, {headers}) + return this.http.get(this.urlGetExpiredProjectLogs) .pipe( map(body => { console.log(body.message); @@ -1248,12 +1098,7 @@ export class ProjectService { } deleteExpiredProjectLog(logID: number): Observable { - const headers = new HttpHeaders().set( - 'Authorization', - `Bearer ${localStorage.getItem('jwt')}` - ); - - return this.http.delete(this.urlDeleteExpiredProjectLog + logID, {headers}).pipe( + return this.http.delete(this.urlDeleteExpiredProjectLog + logID).pipe( map(body => { console.log(body.message); return body; diff --git a/src/Analysim.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs index 99f10e29..2b61b867 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using Core.Interfaces; using Core.Services; using Infrastructure.Data; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -28,6 +29,7 @@ using System.Data; using Analysim.Core.Entities; using Microsoft.AspNetCore.Authorization; +using Web.Services; namespace Web.Controllers { @@ -41,13 +43,15 @@ public class AccountController : ControllerBase private readonly ApplicationDbContext _dbContext; private readonly ILoggerManager _loggerManager; private readonly IMailNetService _mailNetService; + private readonly AuthTokenService _authTokenService; private readonly IConfiguration _configuration; public AccountController(IOptions jwtSettings, UserManager userManager, SignInManager signManager, ApplicationDbContext dbContext, ILoggerManager loggerManager, - IMailNetService mailNetService,IConfiguration configuration) + IMailNetService mailNetService,IConfiguration configuration, + AuthTokenService authTokenService) { _jwtSettings = jwtSettings.Value; _userManager = userManager; @@ -56,6 +60,90 @@ public AccountController(IOptions jwtSettings, UserManager us _loggerManager = loggerManager; _mailNetService = mailNetService; _configuration = configuration; + _authTokenService = authTokenService; + } + + private const string AccessTokenCookieName = "analysim.access_token"; + private const string RefreshTokenCookieName = "analysim.refresh_token"; + private const string CsrfCookieName = "XSRF-TOKEN"; + private const string LoggedInCookieName = "analysim.logged_in"; + + private string GetRequestIpAddress() + { + return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private void AppendAuthenticationCookies(AuthTokenResult tokenResult) + { + Response.Cookies.Append(AccessTokenCookieName, tokenResult.AccessToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + Expires = tokenResult.AccessTokenExpiresAt, + IsEssential = true + }); + + Response.Cookies.Append(RefreshTokenCookieName, tokenResult.RefreshToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/api/account", + Expires = tokenResult.RefreshTokenExpiresAt, + IsEssential = true + }); + + Response.Cookies.Append(CsrfCookieName, Guid.NewGuid().ToString("N"), new CookieOptions + { + HttpOnly = false, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + Expires = tokenResult.RefreshTokenExpiresAt, + IsEssential = true + }); + + Response.Cookies.Append(LoggedInCookieName, "1", new CookieOptions + { + HttpOnly = false, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + Expires = tokenResult.RefreshTokenExpiresAt, + IsEssential = true + }); + } + + private void ClearAuthenticationCookies() + { + var rootCookieOptions = new CookieOptions + { + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/" + }; + + Response.Cookies.Delete(AccessTokenCookieName, rootCookieOptions); + Response.Cookies.Delete(CsrfCookieName, rootCookieOptions); + Response.Cookies.Delete(LoggedInCookieName, rootCookieOptions); + + Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions + { + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/api/account" + }); + } + + private async Task LoadUserNavigationsAsync(User user) + { + _dbContext.Entry(user).Collection(u => u.ProjectUsers).Load(); + _dbContext.Entry(user).Collection(u => u.BlobFiles).Load(); + _dbContext.Entry(user).Collection(u => u.Followers).Load(); + _dbContext.Entry(user).Collection(u => u.Following).Load(); + await Task.CompletedTask; } #region GET REQUEST @@ -295,8 +383,6 @@ public async Task Register([FromForm] AccountRegisterVM formdata) string registrationCode = registrationSurveyJson.registrationCode; - Console.Write(registrationCode); - if(registrationKeys != null && registrationKeys.Length != 0) { bool containsRegistrationCode = registrationKeys.Contains(registrationCode); @@ -531,7 +617,6 @@ public async Task ForgotPassword([FromForm] ForgotPasswordVM form var code = await _userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - System.Diagnostics.Debug.WriteLine(code); var callbackUrl = Url.Action("ResetPassword", "Account", new { UserId = user.Id, @@ -598,6 +683,9 @@ public async Task changePassword([FromForm] ChangePasswordVM form if(resetPassResult.Result.Succeeded) { + await _authTokenService.RevokeAllUserRefreshTokensAsync(user.Id, GetRequestIpAddress(), "Password changed"); + ClearAuthenticationCookies(); + return Ok(new { result = resetPassResult.Result, @@ -652,15 +740,6 @@ public async Task Login([FromForm] AccountLoginVM formdata) var username = await _userManager.FindByNameAsync(formdata.Username); var email = await _userManager.FindByEmailAsync(formdata.Username); - // Get The User Role - //var roles = await _userManager.GetRolesAsync(user); - - // Generate Key Token - var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtSettings.Secret)); - - // Generate Expiration Time For Token - double tokenExpiryTime = Convert.ToDouble(_jwtSettings.ExpireTime); - // Check Login Status if ((username != null && await _userManager.CheckPasswordAsync(username, formdata.Password)) || (email != null && await _userManager.CheckPasswordAsync(email, formdata.Password))) { @@ -678,48 +757,22 @@ public async Task Login([FromForm] AccountLoginVM formdata) }); } - // Create JWT Token Handler - var tokenHandler = new JwtSecurityTokenHandler(); - - // Create Token Descriptor - var tokenDescriptor = new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(new Claim[] - { - new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - //new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), - //new Claim(ClaimTypes.Role, roles.FirstOrDefault()), - new Claim("LoggedOn", DateTime.UtcNow.ToString()), - new Claim(ClaimTypes.Name, formdata.Username) - }), - - SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature), - Issuer = _jwtSettings.Issuer, - Audience = _jwtSettings.Audience, - Expires = DateTime.UtcNow.AddMinutes(tokenExpiryTime) - }; - - // Create Token - var token = tokenHandler.CreateToken(tokenDescriptor); - // Update Last Online user.LastOnline = DateTime.UtcNow; // Save Database Change await _dbContext.SaveChangesAsync(); - _dbContext.Entry(user).Collection(u => u.ProjectUsers).Load(); - _dbContext.Entry(user).Collection(u => u.BlobFiles).Load(); - _dbContext.Entry(user).Collection(u => u.Followers).Load(); - _dbContext.Entry(user).Collection(u => u.Following).Load(); + await LoadUserNavigationsAsync(user); + + var tokenResult = await _authTokenService.CreateSessionAsync(user, GetRequestIpAddress()); + AppendAuthenticationCookies(tokenResult); // Return OK Request return Ok(new { result = user, - token = tokenHandler.WriteToken(token), - expiration = token.ValidTo, + expiration = tokenResult.AccessTokenExpiresAt, message = "Login successful" }); @@ -737,6 +790,89 @@ public async Task Login([FromForm] AccountLoginVM formdata) } } + /* + * Type : GET + * URL : /api/account/me + * Description: Return the authenticated user for the current access-token cookie + * Response Status: 200 Ok, 401 Unauthorized + */ + [Authorize] + [HttpGet("[action]")] + public async Task Me() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) + { + return Unauthorized(new { message = "User not found." }); + } + + await LoadUserNavigationsAsync(user); + + return Ok(new + { + result = user, + message = "Session is active" + }); + } + + /* + * Type : POST + * URL : /api/account/refresh + * Description: Rotate a valid refresh-token cookie and issue fresh authentication cookies + * Response Status: 200 Ok, 401 Unauthorized + */ + [HttpPost("[action]")] + public async Task Refresh() + { + if (!Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken)) + { + ClearAuthenticationCookies(); + return Unauthorized(new { message = "Authentication renewal failed." }); + } + + var tokenResult = await _authTokenService.RotateRefreshTokenAsync(refreshToken, GetRequestIpAddress()); + if (tokenResult == null) + { + ClearAuthenticationCookies(); + return Unauthorized(new { message = "Authentication renewal failed." }); + } + + await LoadUserNavigationsAsync(tokenResult.User); + AppendAuthenticationCookies(tokenResult); + + return Ok(new + { + result = tokenResult.User, + expiration = tokenResult.AccessTokenExpiresAt, + message = "Authentication renewed" + }); + } + + /* + * Type : POST + * URL : /api/account/logout + * Description: Revoke the current refresh token and clear authentication cookies + * Response Status: 200 Ok + */ + [HttpPost("[action]")] + public async Task Logout() + { + if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken)) + { + await _authTokenService.RevokeRefreshTokenAsync(refreshToken, GetRequestIpAddress(), "User logged out"); + } + + ClearAuthenticationCookies(); + + return Ok(new { message = "Logout successful" }); + } + /* @@ -1064,6 +1200,7 @@ public async Task DeleteUser([FromRoute] int userId) await _dbContext.SaveChangesAsync(); // Remove the user + await _authTokenService.RevokeAllUserRefreshTokensAsync(userId, GetRequestIpAddress(), "User account deleted"); _dbContext.Users.Remove(user); await _dbContext.SaveChangesAsync(); diff --git a/src/Analysim.Web/Extensions/ServiceExtensions.cs b/src/Analysim.Web/Extensions/ServiceExtensions.cs index 7fd1cb8d..acf21b68 100644 --- a/src/Analysim.Web/Extensions/ServiceExtensions.cs +++ b/src/Analysim.Web/Extensions/ServiceExtensions.cs @@ -12,6 +12,9 @@ using System; using System.Text; using Core.Interfaces; +using Microsoft.AspNetCore.Http; +using System.Linq; +using Web.Services; namespace Web.Extensions { @@ -23,7 +26,12 @@ public static void ConfigureCors(this IServiceCollection services) services.AddCors(options => { options.AddPolicy("CorsPolicy", builder => - builder.AllowAnyOrigin() + builder.WithOrigins( + "https://localhost:5001", + "http://localhost:5000", + "https://localhost:4200", + "http://localhost:4200") + .AllowCredentials() .AllowAnyMethod() .AllowAnyHeader()); }); @@ -97,9 +105,28 @@ public static void ConfigureJWT(this IServiceCollection services, IConfiguration ValidIssuer = jwtSettings.GetSection("Issuer").Value, ValidAudience = jwtSettings.GetSection("Audience").Value, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.FromMinutes(1) + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var hasBearerHeader = context.Request.Headers.ContainsKey("Authorization") + && context.Request.Headers["Authorization"].Any(h => h.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)); + + if (!hasBearerHeader && context.Request.Cookies.TryGetValue("analysim.access_token", out var accessToken)) + { + context.Token = accessToken; + } + + return System.Threading.Tasks.Task.CompletedTask; + } }; }); + + services.AddScoped(); } public static void ConfigureSpa(this IServiceCollection services) diff --git a/src/Analysim.Web/Services/AuthTokenResult.cs b/src/Analysim.Web/Services/AuthTokenResult.cs new file mode 100644 index 00000000..db6de4d0 --- /dev/null +++ b/src/Analysim.Web/Services/AuthTokenResult.cs @@ -0,0 +1,18 @@ +using System; +using Core.Entities; + +namespace Web.Services +{ + public class AuthTokenResult + { + public User User { get; set; } + + public string AccessToken { get; set; } + + public DateTime AccessTokenExpiresAt { get; set; } + + public string RefreshToken { get; set; } + + public DateTime RefreshTokenExpiresAt { get; set; } + } +} diff --git a/src/Analysim.Web/Services/AuthTokenService.cs b/src/Analysim.Web/Services/AuthTokenService.cs new file mode 100644 index 00000000..b95a0516 --- /dev/null +++ b/src/Analysim.Web/Services/AuthTokenService.cs @@ -0,0 +1,215 @@ +using Core.Entities; +using Core.Helper; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Linq; +using System.Threading.Tasks; + +namespace Web.Services +{ + public class AuthTokenService + { + private readonly ApplicationDbContext _dbContext; + private readonly JwtSettings _jwtSettings; + + public AuthTokenService(ApplicationDbContext dbContext, IOptions jwtSettings) + { + _dbContext = dbContext; + _jwtSettings = jwtSettings.Value; + } + + public async Task CreateSessionAsync(User user, string ipAddress) + { + var refreshToken = GenerateRefreshToken(); + var refreshTokenHash = HashToken(refreshToken); + var refreshExpiresAt = DateTime.UtcNow.AddDays(GetRefreshTokenLifetimeDays()); + + await _dbContext.RefreshTokens.AddAsync(new RefreshToken + { + UserID = user.Id, + TokenHash = refreshTokenHash, + TokenFamilyID = Guid.NewGuid(), + CreatedAt = DateTime.UtcNow, + ExpiresAt = refreshExpiresAt, + CreatedByIp = ipAddress + }); + + await _dbContext.SaveChangesAsync(); + + return CreateTokenResult(user, refreshToken, refreshExpiresAt); + } + + public async Task RotateRefreshTokenAsync(string refreshToken, string ipAddress) + { + if (string.IsNullOrWhiteSpace(refreshToken)) + { + return null; + } + + var tokenHash = HashToken(refreshToken); + var storedToken = await _dbContext.RefreshTokens + .Include(rt => rt.User) + .SingleOrDefaultAsync(rt => rt.TokenHash == tokenHash); + + if (storedToken == null) + { + return null; + } + + if (!storedToken.IsActive) + { + await RevokeTokenFamilyAsync(storedToken.TokenFamilyID, ipAddress, "Refresh token reuse detected"); + return null; + } + + var replacementToken = GenerateRefreshToken(); + var replacementHash = HashToken(replacementToken); + var replacementExpiresAt = DateTime.UtcNow.AddDays(GetRefreshTokenLifetimeDays()); + + storedToken.RevokedAt = DateTime.UtcNow; + storedToken.RevokedByIp = ipAddress; + storedToken.RevokedReason = "Replaced by rotated refresh token"; + storedToken.ReplacedByTokenHash = replacementHash; + + await _dbContext.RefreshTokens.AddAsync(new RefreshToken + { + UserID = storedToken.UserID, + TokenHash = replacementHash, + TokenFamilyID = storedToken.TokenFamilyID, + CreatedAt = DateTime.UtcNow, + ExpiresAt = replacementExpiresAt, + CreatedByIp = ipAddress + }); + + await _dbContext.SaveChangesAsync(); + + return CreateTokenResult(storedToken.User, replacementToken, replacementExpiresAt); + } + + public async Task RevokeRefreshTokenAsync(string refreshToken, string ipAddress, string reason) + { + if (string.IsNullOrWhiteSpace(refreshToken)) + { + return; + } + + var tokenHash = HashToken(refreshToken); + var storedToken = await _dbContext.RefreshTokens.SingleOrDefaultAsync(rt => rt.TokenHash == tokenHash); + + if (storedToken == null || storedToken.IsRevoked) + { + return; + } + + storedToken.RevokedAt = DateTime.UtcNow; + storedToken.RevokedByIp = ipAddress; + storedToken.RevokedReason = reason; + + await _dbContext.SaveChangesAsync(); + } + + public async Task RevokeAllUserRefreshTokensAsync(int userId, string ipAddress, string reason) + { + var tokens = await _dbContext.RefreshTokens + .Where(rt => rt.UserID == userId && rt.RevokedAt == null) + .ToListAsync(); + + foreach (var token in tokens) + { + token.RevokedAt = DateTime.UtcNow; + token.RevokedByIp = ipAddress; + token.RevokedReason = reason; + } + + await _dbContext.SaveChangesAsync(); + } + + public static string HashToken(string token) + { + using var sha256 = SHA256.Create(); + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(bytes); + } + + private async Task RevokeTokenFamilyAsync(Guid tokenFamilyId, string ipAddress, string reason) + { + var activeFamilyTokens = await _dbContext.RefreshTokens + .Where(rt => rt.TokenFamilyID == tokenFamilyId && rt.RevokedAt == null) + .ToListAsync(); + + foreach (var token in activeFamilyTokens) + { + token.RevokedAt = DateTime.UtcNow; + token.RevokedByIp = ipAddress; + token.RevokedReason = reason; + } + + await _dbContext.SaveChangesAsync(); + } + + private AuthTokenResult CreateTokenResult(User user, string refreshToken, DateTime refreshExpiresAt) + { + var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(GetAccessTokenLifetimeMinutes()); + + return new AuthTokenResult + { + User = user, + AccessToken = CreateAccessToken(user, accessTokenExpiresAt), + AccessTokenExpiresAt = accessTokenExpiresAt, + RefreshToken = refreshToken, + RefreshTokenExpiresAt = refreshExpiresAt + }; + } + + private string CreateAccessToken(User user, DateTime expiresAt) + { + var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtSettings.Secret)); + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim("LoggedOn", DateTime.UtcNow.ToString("O")), + new Claim(ClaimTypes.Name, user.UserName) + }), + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature), + Issuer = _jwtSettings.Issuer, + Audience = _jwtSettings.Audience, + Expires = expiresAt + }; + + return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); + } + + private static string GenerateRefreshToken() + { + var randomBytes = RandomNumberGenerator.GetBytes(64); + return Base64UrlEncoder.Encode(randomBytes); + } + + private double GetAccessTokenLifetimeMinutes() + { + if (double.TryParse(_jwtSettings.ExpireTime, out var minutes) && minutes > 0) + { + return minutes; + } + + return 15; + } + + private int GetRefreshTokenLifetimeDays() + { + return _jwtSettings.RefreshTokenExpireDays > 0 ? _jwtSettings.RefreshTokenExpireDays : 14; + } + } +} diff --git a/src/Analysim.Web/Startup.cs b/src/Analysim.Web/Startup.cs index 028ed82d..9f6d65a2 100644 --- a/src/Analysim.Web/Startup.cs +++ b/src/Analysim.Web/Startup.cs @@ -2,12 +2,15 @@ using Core.Interfaces; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.SpaServices.AngularCli; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System; +using System.Linq; using Web.Extensions; namespace Web @@ -129,6 +132,43 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerM app.UseRouting(); + app.Use(async (context, next) => + { + var method = context.Request.Method; + var isUnsafeApiRequest = context.Request.Path.StartsWithSegments("/api") + && !HttpMethods.IsGet(method) + && !HttpMethods.IsHead(method) + && !HttpMethods.IsOptions(method) + && !HttpMethods.IsTrace(method); + + var usesAuthCookie = context.Request.Cookies.ContainsKey("analysim.access_token") + || context.Request.Cookies.ContainsKey("analysim.refresh_token"); + + var path = context.Request.Path.Value?.ToLowerInvariant() ?? string.Empty; + var isPublicAuthRequest = path == "/api/account/login" + || path == "/api/account/register" + || path == "/api/account/forgotpassword" + || path == "/api/account/changepassword" + || path == "/api/account/confirmemailpost"; + + if (isUnsafeApiRequest && usesAuthCookie && !isPublicAuthRequest) + { + var csrfCookie = context.Request.Cookies["XSRF-TOKEN"]; + var csrfHeader = context.Request.Headers["X-XSRF-TOKEN"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(csrfCookie) + || string.IsNullOrWhiteSpace(csrfHeader) + || !string.Equals(csrfCookie, csrfHeader, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { message = "Invalid CSRF token." }); + return; + } + } + + await next(); + }); + app.UseAuthentication(); app.UseAuthorization(); From 232df8e37e0e199d1b65840e3d8d2f896af6dac1 Mon Sep 17 00:00:00 2001 From: Mohab Date: Sat, 18 Jul 2026 22:22:17 +0300 Subject: [PATCH 2/5] allow enabling and disabling user accounts --- .../user-display/user-display.component.html | 12 ++- .../user-display/user-display.component.ts | 20 +++++ .../components/users/users.component.html | 14 +++- .../admin/components/users/users.component.ts | 4 + .../ClientApp/src/app/interfaces/user.ts | 4 +- .../src/app/services/account.service.ts | 14 ++++ .../Controllers/AccountController.cs | 75 +++++++++++++++++++ .../Extensions/ServiceExtensions.cs | 28 +++++++ src/Analysim.Web/Services/AuthTokenService.cs | 6 ++ .../Account/AccountStatusUpdateVM.cs | 7 ++ 10 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/Analysim.Web/ViewModels/Account/AccountStatusUpdateVM.cs diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/users/user-display/user-display.component.html b/src/Analysim.Web/ClientApp/src/app/admin/components/users/user-display/user-display.component.html index d8bc70ff..fa74b810 100644 --- a/src/Analysim.Web/ClientApp/src/app/admin/components/users/user-display/user-display.component.html +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/users/user-display/user-display.component.html @@ -1,7 +1,17 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts index 6cdd120f..9ab20df9 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { FormGroup, FormControl, FormBuilder } from '@angular/forms'; +import { FormGroup, FormControl, FormBuilder, Validators } from '@angular/forms'; import { AccountService } from 'src/app/services/account.service'; import { Router } from '@angular/router'; import { User } from 'src/app/interfaces/user'; @@ -40,6 +40,7 @@ export class ProfileSettingComponent implements OnInit { this.getProfileImage(); this.buildProfileBioForm(); + this.buildPasswordForm(); this.buildNotificationsPrefForm(); }); } @@ -147,8 +148,75 @@ export class ProfileSettingComponent implements OnInit { // PASSWORD FORM passwordForm: FormGroup; + currentPassword: FormControl; + newPassword: FormControl; + confirmPassword: FormControl; + passwordUpdateLoading = false; + passwordErrorMessage: string = null; + + buildPasswordForm() { + this.currentPassword = new FormControl('', [Validators.required]); + this.newPassword = new FormControl('', [ + Validators.required, + Validators.minLength(6), + ]); + this.confirmPassword = new FormControl('', [Validators.required]); + + this.passwordForm = this.formBuilder.group({ + currentPassword: this.currentPassword, + newPassword: this.newPassword, + confirmPassword: this.confirmPassword, + }); + } + onChangePassword() { - // call to change password + this.passwordErrorMessage = null; + + if (!this.passwordForm || this.passwordForm.invalid) { + this.passwordForm.markAllAsTouched(); + return; + } + + const form = this.passwordForm.value; + if (form.newPassword !== form.confirmPassword) { + this.passwordErrorMessage = 'New password and confirmation password do not match.'; + return; + } + + this.passwordUpdateLoading = true; + this.accountService.changeCurrentPassword( + form.currentPassword, + form.newPassword, + form.confirmPassword, + ).subscribe( + () => { + this.passwordUpdateLoading = false; + this.passwordForm.reset(); + this.notif.showSuccess( + 'Password changed successfully. Please log in again.', + 'Password Update', + ); + this.accountService.clearAuthenticationState(); + this.router.navigate(['/login']); + }, + (error) => { + this.passwordUpdateLoading = false; + this.passwordErrorMessage = this.getPasswordErrorMessage(error); + this.notif.showMessage(this.passwordErrorMessage, 'Password Update'); + }, + ); + } + + private getPasswordErrorMessage(error): string { + if (error?.error?.errors && Array.isArray(error.error.errors)) { + return error.error.errors[0]; + } + + if (error?.error?.message) { + return error.error.message; + } + + return 'Password could not be changed. Please check your current password and try again.'; } // NOTIFICATION FORM diff --git a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts index 05098757..9e8c1bdd 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts @@ -45,6 +45,7 @@ export class AccountService { private urlForgotPassEmail: string = this.baseUrl + "forgotPassword/" private urlResetPassword: string = this.baseUrl + "resetPassword?" private urlChangePassword: string = this.baseUrl + "changePassword" + private urlChangeCurrentPassword: string = this.baseUrl + "changeCurrentPassword" private urlReSendVerification: string = this.baseUrl + "sendConfirmationEmail" private urlUpdateNotificationPreferences: string = this.baseUrl + "updatenotificationpreferences/" private urlSetAccountStatus: string = this.baseUrl + "setAccountStatus/" @@ -306,6 +307,25 @@ export class AccountService { ) } + changeCurrentPassword(currentPassword: string, newPassword: string, confirmPassword: string): Observable { + const body = { + currentPassword, + newPassword, + confirmPassword + } + + return this.http.post(this.urlChangeCurrentPassword, body) + .pipe( + map(body => { + return body + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + uploadProfileImage(file: any, userID: number): Observable { let body = new FormData() body.append('file', file) diff --git a/src/Analysim.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs index 7573739f..ec063331 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -716,6 +716,64 @@ public async Task changePassword([FromForm] ChangePasswordVM form }); } + /* + * Type : POST + * URL : /api/account/changeCurrentPassword + * Description: Changes the authenticated user's password after verifying the current password + * Response Status: 200 Ok, 400 Bad Request, 401 Unauthorized + */ + [Authorize] + [HttpPost("[action]")] + public async Task ChangeCurrentPassword([FromBody] CurrentPasswordChangeVM formdata) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return Unauthorized(new { message = "User not found." }); + } + + if (IsUserDisabled(user)) + { + return Forbid(); + } + + var result = await _userManager.ChangePasswordAsync( + user, + formdata.CurrentPassword, + formdata.NewPassword + ); + + if (!result.Succeeded) + { + return BadRequest(new + { + result, + message = "Password change failed", + errors = result.Errors.Select(e => e.Description) + }); + } + + await _authTokenService.RevokeAllUserRefreshTokensAsync(user.Id, GetRequestIpAddress(), "Password changed"); + ClearAuthenticationCookies(); + + return Ok(new + { + result, + message = "Password successfully changed. Please log in again." + }); + } + /* * Type : Post * URL : /api/account/testGenerateToken? diff --git a/src/Analysim.Web/ViewModels/Account/CurrentPasswordChangeVM.cs b/src/Analysim.Web/ViewModels/Account/CurrentPasswordChangeVM.cs new file mode 100644 index 00000000..dd4852de --- /dev/null +++ b/src/Analysim.Web/ViewModels/Account/CurrentPasswordChangeVM.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Web.ViewModels.Account +{ + public class CurrentPasswordChangeVM + { + [Required(ErrorMessage = "Current password is required.")] + [DataType(DataType.Password)] + public string CurrentPassword { get; set; } + + [Required(ErrorMessage = "New password is required.")] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + public string NewPassword { get; set; } + + [Required(ErrorMessage = "Confirm password is required.")] + [DataType(DataType.Password)] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } +}