From 16ae68a02ef743c39a08d5c5c196a1686c3a0363 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 17 Apr 2025 04:33:28 -0400 Subject: [PATCH] Adds supports for RefreshTokens --- .../Web/Common/Security/PasswordGenerator.cs | 2 +- .../Common/Security/RefreshTokenGenerator.cs | 14 + .../Features/Users/{ => Data}/IdentityRole.cs | 2 +- .../Users/{ => Data}/IdentityService.cs | 2 +- .../Features/Users/{ => Data}/IdentityUser.cs | 4 +- .../Users/{ => Data}/IdentityUserManager.cs | 2 +- ...0250417060553_AddsRefreshToken.Designer.cs | 315 ++++++++++++++++++ .../20250417060553_AddsRefreshToken.cs | 45 +++ .../ApplicationDbContextModelSnapshot.cs | 23 +- .../Web/Features/Users/DependencyInjection.cs | 2 + .../Features/Users/Handlers/ChangeAddress.cs | 1 + .../Features/Users/Handlers/ChangeAlias.cs | 1 + .../Users/Handlers/ChangeBirthDate.cs | 1 + .../Features/Users/Handlers/ChangeEmail.cs | 1 + .../Features/Users/Handlers/ChangeFullname.cs | 1 + .../Features/Users/Handlers/ChangePhone.cs | 1 + .../Features/Users/Handlers/ChangePortrait.cs | 1 + .../Features/Users/Handlers/GetCurrentUser.cs | 6 +- .../Handlers/GetCurrentUserProfilePicture.cs | 7 + .../Users/Handlers/LoginWithFacebook.cs | 29 +- .../Users/Handlers/LoginWithGoogle.cs | 26 +- .../Features/Users/Handlers/RefreshToken.cs | 76 +++++ backend/src/Web/Features/Users/JwtOptions.cs | 3 + backend/src/Web/appsettings.json | 4 +- frontend/src/plugins/api.js | 46 ++- frontend/src/stores/authStore.js | 75 +++-- frontend/src/stores/creatorProfileStore.js | 2 +- frontend/src/stores/userProfileStore.js | 4 +- 28 files changed, 620 insertions(+), 76 deletions(-) create mode 100644 backend/src/Web/Common/Security/RefreshTokenGenerator.cs rename backend/src/Web/Features/Users/{ => Data}/IdentityRole.cs (81%) rename backend/src/Web/Features/Users/{ => Data}/IdentityService.cs (98%) rename backend/src/Web/Features/Users/{ => Data}/IdentityUser.cs (80%) rename backend/src/Web/Features/Users/{ => Data}/IdentityUserManager.cs (94%) create mode 100644 backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.Designer.cs create mode 100644 backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.cs create mode 100644 backend/src/Web/Features/Users/Handlers/RefreshToken.cs diff --git a/backend/src/Web/Common/Security/PasswordGenerator.cs b/backend/src/Web/Common/Security/PasswordGenerator.cs index 80b55e1..715243a 100644 --- a/backend/src/Web/Common/Security/PasswordGenerator.cs +++ b/backend/src/Web/Common/Security/PasswordGenerator.cs @@ -13,7 +13,7 @@ public static class PasswordGenerator private static readonly Random Random = new(); - public static string GeneratePassword( + public static string Next( int length = 15, bool requireNumber = true, bool requireLowercase = true, diff --git a/backend/src/Web/Common/Security/RefreshTokenGenerator.cs b/backend/src/Web/Common/Security/RefreshTokenGenerator.cs new file mode 100644 index 0000000..cad0a93 --- /dev/null +++ b/backend/src/Web/Common/Security/RefreshTokenGenerator.cs @@ -0,0 +1,14 @@ +using System.Security.Cryptography; + +namespace Hutopy.Web.Common.Security; + +public static class RefreshTokenGenerator +{ + public static string Next() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } +} diff --git a/backend/src/Web/Features/Users/IdentityRole.cs b/backend/src/Web/Features/Users/Data/IdentityRole.cs similarity index 81% rename from backend/src/Web/Features/Users/IdentityRole.cs rename to backend/src/Web/Features/Users/Data/IdentityRole.cs index d5e68f1..66b999f 100644 --- a/backend/src/Web/Features/Users/IdentityRole.cs +++ b/backend/src/Web/Features/Users/Data/IdentityRole.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -namespace Hutopy.Web.Features.Users; +namespace Hutopy.Web.Features.Users.Data; public class IdentityRole : IdentityRole { diff --git a/backend/src/Web/Features/Users/IdentityService.cs b/backend/src/Web/Features/Users/Data/IdentityService.cs similarity index 98% rename from backend/src/Web/Features/Users/IdentityService.cs rename to backend/src/Web/Features/Users/Data/IdentityService.cs index cc8e21b..b55ea8c 100644 --- a/backend/src/Web/Features/Users/IdentityService.cs +++ b/backend/src/Web/Features/Users/Data/IdentityService.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using Hutopy.Web.Features.Users.Models; -namespace Hutopy.Web.Features.Users; +namespace Hutopy.Web.Features.Users.Data; public class IdentityService( IdentityUserManager userManager, diff --git a/backend/src/Web/Features/Users/IdentityUser.cs b/backend/src/Web/Features/Users/Data/IdentityUser.cs similarity index 80% rename from backend/src/Web/Features/Users/IdentityUser.cs rename to backend/src/Web/Features/Users/Data/IdentityUser.cs index c1d9327..3383a0b 100644 --- a/backend/src/Web/Features/Users/IdentityUser.cs +++ b/backend/src/Web/Features/Users/Data/IdentityUser.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Identity; -namespace Hutopy.Web.Features.Users; +namespace Hutopy.Web.Features.Users.Data; public class IdentityUser : IdentityUser { @@ -13,5 +13,7 @@ public class IdentityUser : IdentityUser [MaxLength(2048)] public string? PortraitUrl { get; set; } [MaxLength(255)] public string? GoogleId { get; set; } [MaxLength(255)] public string? FacebookId { get; set; } + public string RefreshToken { get; set; } + public DateTime RefreshTokenExpiryTime { get; set; } } diff --git a/backend/src/Web/Features/Users/IdentityUserManager.cs b/backend/src/Web/Features/Users/Data/IdentityUserManager.cs similarity index 94% rename from backend/src/Web/Features/Users/IdentityUserManager.cs rename to backend/src/Web/Features/Users/Data/IdentityUserManager.cs index e74ed46..20b9c7c 100644 --- a/backend/src/Web/Features/Users/IdentityUserManager.cs +++ b/backend/src/Web/Features/Users/Data/IdentityUserManager.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -namespace Hutopy.Web.Features.Users; +namespace Hutopy.Web.Features.Users.Data; public sealed class IdentityUserManager( IUserStore store, diff --git a/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.Designer.cs b/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.Designer.cs new file mode 100644 index 0000000..2a6a396 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.Designer.cs @@ -0,0 +1,315 @@ +// +using System; +using Hutopy.Web.Features.Users.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 Hutopy.Web.Features.Users.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250417060553_AddsRefreshToken")] + partial class AddsRefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Identity") + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Users.Data.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + 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", "Identity"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Users.Data.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Alias") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Firstname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Lastname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + 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("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + 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", "Identity"); + }); + + 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("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "Identity"); + }); + + 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("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "Identity"); + }); + + 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("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.cs b/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.cs new file mode 100644 index 0000000..1ef2cf5 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/Migrations/20250417060553_AddsRefreshToken.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Users.Data.Migrations +{ + /// + public partial class AddsRefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RefreshToken", + schema: "Identity", + table: "AspNetUsers", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "RefreshTokenExpiryTime", + schema: "Identity", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RefreshToken", + schema: "Identity", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "RefreshTokenExpiryTime", + schema: "Identity", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 2f23681..1e1c5e9 100644 --- a/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -23,7 +23,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityRole", b => + modelBuilder.Entity("Hutopy.Web.Features.Users.Data.IdentityRole", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -50,7 +50,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations b.ToTable("AspNetRoles", "Identity"); }); - modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityUser", b => + modelBuilder.Entity("Hutopy.Web.Features.Users.Data.IdentityUser", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -124,6 +124,13 @@ namespace Hutopy.Web.Features.Users.Data.Migrations .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + b.Property("SecurityStamp") .HasColumnType("text"); @@ -251,7 +258,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -260,7 +267,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -269,7 +276,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -278,13 +285,13 @@ namespace Hutopy.Web.Features.Users.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -293,7 +300,7 @@ namespace Hutopy.Web.Features.Users.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + b.HasOne("Hutopy.Web.Features.Users.Data.IdentityUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/backend/src/Web/Features/Users/DependencyInjection.cs b/backend/src/Web/Features/Users/DependencyInjection.cs index 5ef9e3a..977eef9 100644 --- a/backend/src/Web/Features/Users/DependencyInjection.cs +++ b/backend/src/Web/Features/Users/DependencyInjection.cs @@ -2,6 +2,8 @@ using Hutopy.Web.Features.Messages.Data; using Hutopy.Web.Features.Users.Data; using Microsoft.AspNetCore.Identity; +using IdentityRole = Hutopy.Web.Features.Users.Data.IdentityRole; +using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser; namespace Hutopy.Web.Features.Users; diff --git a/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs b/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs index a8ebc1d..a45c727 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs b/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs index 320a285..c697987 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs b/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs index 4800321..7fce284 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs b/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs index 64b8286..ca1f1c8 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs b/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs index d130a0c..7a9a4f6 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangePhone.cs b/backend/src/Web/Features/Users/Handlers/ChangePhone.cs index e13d1d2..91c3597 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangePhone.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangePhone.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs b/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs index 31b90d1..676db7c 100644 --- a/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs +++ b/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs @@ -1,5 +1,6 @@ using Hutopy.Web.Common.BlobStorage; using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; diff --git a/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs b/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs index 1580e2f..41fb072 100644 --- a/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs +++ b/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs @@ -1,13 +1,11 @@ using Hutopy.Web.Features.Users.Handlers.Models; -using Hutopy.Web.Features.Memberships.Data; -using Hutopy.Web.Features.Memberships.Infrastructure; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; [PublicAPI] public class GetCurrentUserQueryHandler( - IdentityService identityService, - MembershipDbContext membershipDbContext) + IdentityService identityService) : EndpointWithoutRequest { public override void Configure() diff --git a/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs b/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs index 3ffee88..0d5f0fc 100644 --- a/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs +++ b/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs @@ -1,4 +1,5 @@ using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Features.Users.Data; namespace Hutopy.Web.Features.Users.Handlers; @@ -20,6 +21,12 @@ public class GetCurrentUserPortraitHandler( { var identityUser = await identityService.GetCurrentUserAsync(); + if (identityUser is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + var stream = await blobStorage.DownloadFileAsync( ContainerNames.Users, $"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", diff --git a/backend/src/Web/Features/Users/Handlers/LoginWithFacebook.cs b/backend/src/Web/Features/Users/Handlers/LoginWithFacebook.cs index b614e6a..212f0b8 100644 --- a/backend/src/Web/Features/Users/Handlers/LoginWithFacebook.cs +++ b/backend/src/Web/Features/Users/Handlers/LoginWithFacebook.cs @@ -1,11 +1,14 @@ using System.Text.Json; using System.Text.Json.Serialization; using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; +using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser; namespace Hutopy.Web.Features.Users.Handlers; +[PublicAPI] public class FacebookUserInfo { [JsonPropertyName("id")] public required string Id { get; init; } @@ -14,11 +17,13 @@ public class FacebookUserInfo [JsonPropertyName("picture")] public required FacebookPictureData Picture { get; init; } } +[PublicAPI] public class FacebookPictureData { [JsonPropertyName("data")] public required FacebookPicture Picture { get; init; } } +[PublicAPI] public class FacebookPicture { [JsonPropertyName("url")] public required string Url { get; init; } @@ -80,10 +85,10 @@ public class LoginWithFacebookHandler( // Check if user exists or create a new one var user = await userManager.FindByEmailAsync(userInfo.Email!); - + if (user is null) { - var generatedPassword = PasswordGenerator.GeneratePassword(); + var generatedPassword = PasswordGenerator.Next(); var generatedUser = new IdentityUser { UserName = userInfo.Email ?? $"fb_{userInfo.Id}", @@ -94,11 +99,11 @@ public class LoginWithFacebookHandler( PortraitUrl = userInfo.Picture.Picture.Url, FacebookId = userInfo.Id, // Storing Facebook ID }; - + var result = await userManager.CreateAsync( generatedUser, generatedPassword); - + if (!result.Succeeded) { await SendStringAsync( @@ -113,20 +118,28 @@ public class LoginWithFacebookHandler( await signInManager.SignInAsync(user, isPersistent: false); + // Generate refresh token + var refreshToken = RefreshTokenGenerator.Next(); + + // Store refresh token in user's properties + user.RefreshToken = refreshToken; + user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + await userManager.UpdateAsync(user); + var accessToken = JwtTokenHelper.GenerateJwtToken( expiresIn: jwtOptions.Value.Lifetime, issuer: jwtOptions.Value.Issuer, audience: jwtOptions.Value.Audience, key: jwtOptions.Value.Key, userId: user.Id.ToString(), - email: user.Email, + email: user.Email ?? string.Empty, alias: user.Alias, - firstname: user.Firstname, - lastname: user.Lastname, + firstname: user.Firstname ?? string.Empty, + lastname: user.Lastname ?? string.Empty, portraitUrl: user.PortraitUrl); await SendOkAsync( - new LoginWithFacebookResponse(accessToken, string.Empty), + new LoginWithFacebookResponse(accessToken, refreshToken), cancellation: ct); } } diff --git a/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs b/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs index 4919faa..b9cce4e 100644 --- a/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs +++ b/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs @@ -1,8 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; +using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser; namespace Hutopy.Web.Features.Users.Handlers; @@ -87,10 +89,10 @@ public class LoginWithGoogleHandler( // Check if user exists or create a new one var user = await userManager.FindByEmailAsync(userInfo.Email); - + if (user is null) { - var generatedPassword = PasswordGenerator.GeneratePassword(); + var generatedPassword = PasswordGenerator.Next(); var generatedUser = new IdentityUser { UserName = userInfo.Email, @@ -101,11 +103,11 @@ public class LoginWithGoogleHandler( PortraitUrl = userInfo.Picture, GoogleId = userInfo.Id, }; - + var result = await userManager.CreateAsync( generatedUser, generatedPassword); - + if (!result.Succeeded) { await SendStringAsync( @@ -120,20 +122,28 @@ public class LoginWithGoogleHandler( await signInManager.SignInAsync(user, isPersistent: false); + // Generate refresh token + var refreshToken = RefreshTokenGenerator.Next(); + + // Store refresh token in user's properties + user.RefreshToken = refreshToken; + user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + await userManager.UpdateAsync(user); + var accessToken = JwtTokenHelper.GenerateJwtToken( expiresIn: jwtOptions.Value.Lifetime, issuer: jwtOptions.Value.Issuer, audience: jwtOptions.Value.Audience, key: jwtOptions.Value.Key, userId: user.Id.ToString(), - email: user.Email, + email: user.Email ?? string.Empty, alias: user.Alias, - firstname: user.Firstname, - lastname: user.Lastname, + firstname: user.Firstname ?? string.Empty, + lastname: user.Lastname ?? string.Empty, portraitUrl: user.PortraitUrl); await SendOkAsync( - new LoginWithGoogleResponse(accessToken, string.Empty), + new LoginWithGoogleResponse(accessToken, refreshToken), cancellation: ct); } } diff --git a/backend/src/Web/Features/Users/Handlers/RefreshToken.cs b/backend/src/Web/Features/Users/Handlers/RefreshToken.cs new file mode 100644 index 0000000..7000347 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/RefreshToken.cs @@ -0,0 +1,76 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Users.Data; +using Microsoft.Extensions.Options; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record RefreshTokenRequest( + string RefreshToken); + +[PublicAPI] +public record RefreshTokenResponse( + string AccessToken, + string RefreshToken); + +[PublicAPI] +public class RefreshTokenHandler( + IdentityUserManager userManager, + IOptionsSnapshot jwtOptions) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Post("/api/users/refresh"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + RefreshTokenRequest request, + CancellationToken ct) + { + // Find user with the refresh token + var user = await userManager.Users + .FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken, ct); + + if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow) + { + await SendUnauthorizedAsync(ct); + return; + } + + // Generate new refresh token if rotation is required + string newRefreshToken; + if (jwtOptions.Value.RefreshTokenRequireRotation) + { + newRefreshToken = RefreshTokenGenerator.Next(); + user.RefreshToken = newRefreshToken; + } + else + { + newRefreshToken = user.RefreshToken; + } + + // Update refresh token expiry time + user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + await userManager.UpdateAsync(user); + + // Generate new access token + var accessToken = JwtTokenHelper.GenerateJwtToken( + expiresIn: jwtOptions.Value.Lifetime, + issuer: jwtOptions.Value.Issuer, + audience: jwtOptions.Value.Audience, + key: jwtOptions.Value.Key, + userId: user.Id.ToString(), + email: user.Email ?? string.Empty, + alias: user.Alias, + firstname: user.Firstname ?? string.Empty, + lastname: user.Lastname ?? string.Empty, + portraitUrl: user.PortraitUrl); + + await SendOkAsync( + new RefreshTokenResponse(accessToken, newRefreshToken), + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Users/JwtOptions.cs b/backend/src/Web/Features/Users/JwtOptions.cs index 7ee27a2..b5241cb 100644 --- a/backend/src/Web/Features/Users/JwtOptions.cs +++ b/backend/src/Web/Features/Users/JwtOptions.cs @@ -8,4 +8,7 @@ public record JwtOptions public required string Issuer { get; init; } public required string Audience { get; init; } public required string Key { get; init; } + + public TimeSpan RefreshTokenLifetime { get; init; } + public bool RefreshTokenRequireRotation { get; init; } } diff --git a/backend/src/Web/appsettings.json b/backend/src/Web/appsettings.json index feae649..ad33e0e 100644 --- a/backend/src/Web/appsettings.json +++ b/backend/src/Web/appsettings.json @@ -16,7 +16,9 @@ "Lifetime": "00:30:00", "Audience": "hutopy", "Issuer": "https://auth.hutopy.com", - "Key": "b2df428b9929d3ace7c598bbf4e496b2f0b71ab3cd4f94540356cfc35b000000" + "Key": "b2df428b9929d3ace7c598bbf4e496b2f0b71ab3cd4f94540356cfc35b000000", + "RefreshTokenLifetime": "7.00:00:00", + "RefreshTokenRequireRotation": true } }, "Contents": { diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js index d3e47bf..d823b7f 100644 --- a/frontend/src/plugins/api.js +++ b/frontend/src/plugins/api.js @@ -4,42 +4,52 @@ import {useAuthStore} from "@/stores/authStore.js" export function useClient() { if (!import.meta.env.VITE_API_URL) throw new Error("VITE_API_URL is not provided") - const api = axios.create({ + const authStore = useAuthStore() + const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, - timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, }); - const authStore = useAuthStore() - const requestInterceptor = (config) => { + // Request interceptor + client.interceptors.request.use(async (config) => { + // Proactively check and refresh token if needed if (authStore.isAuthenticated) { - config.headers["Authorization"] = `Bearer ${authStore.accessToken}` + try { + console.log('within api call') + await authStore.ensureValidToken(); + } catch (error) { + console.error('Failed to ensure valid token:', error); + } } - return config - } - api.interceptors.request.use(requestInterceptor); + if (authStore.isAuthenticated) { + config.headers.Authorization = `Bearer ${authStore.accessToken}`; + } + return config; + }); - // Add response interceptor for token refresh - api.interceptors.response.use( + // Response interceptor + client.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; - // If error is 401 and we haven't tried to refresh the token yet + // If the error is 401 and we haven't tried to refresh the token yet if (error.response?.status === 401 && !originalRequest._retry) { + console.log('Received 401 error, attempting token refresh...'); originalRequest._retry = true; try { - // Attempt to refresh the token await authStore.refresh(); - + console.log('Token refresh successful, retrying original request...'); // Retry the original request with the new token - originalRequest.headers["Authorization"] = `Bearer ${authStore.accessToken}`; - return api(originalRequest); + return client(originalRequest); } catch (refreshError) { - // If refresh fails, logout the user + console.error('Token refresh failed, logging out user:', refreshError); await authStore.logout(); - return Promise.reject(refreshError); + throw refreshError; } } @@ -47,6 +57,6 @@ export function useClient() { } ); - return api; + return client; } diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index dc2160d..6e8ca6f 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -1,5 +1,5 @@ import {defineStore} from 'pinia'; -import {computed} from "vue"; +import {computed, ref} from "vue"; import {useRouter} from "vue-router"; import {useClient} from "@/plugins/api.js"; import {useSessionStorage} from "@vueuse/core"; @@ -9,11 +9,12 @@ function getClaimsFromToken(token) { try { return jwtDecode(token); } catch (error) { + console.error('Failed to decode token:', error); return null; } } -function isTokenExpired(token) { +function isTokenExpiringSoon(token) { if (!token) return true; const claims = getClaimsFromToken(token); if (!claims) return true; @@ -29,11 +30,16 @@ export const useAuthStore = defineStore( () => { const clientApi = useClient() const router = useRouter() + + // Flag to track if we're currently refreshing the token + const isRefreshing = ref(false) + // Store the refresh promise to avoid multiple concurrent refreshes + let refreshPromise = null const accessToken = useSessionStorage('auth-accessToken', undefined) const refreshToken = useSessionStorage('auth-refreshToken', undefined) - const isAuthenticated = computed(() => !!accessToken.value && !isTokenExpired(accessToken.value)) + const isAuthenticated = computed(() => !!accessToken.value) const userId = computed(() => { const claims = getClaimsFromToken(accessToken.value) @@ -111,29 +117,55 @@ export const useAuthStore = defineStore( throw new Error('No refresh token available'); } - try { - const response = await clientApi.post( - 'api/users/refresh', - { - refreshToken: refreshToken.value - }); - - updateTokens({ - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken - }); - return true; - } catch (error) { - console.error('Token refresh failed:', error); - cleanTokens(); - throw error; + // If we're already refreshing, return the existing promise + if (isRefreshing.value && refreshPromise) { + return refreshPromise; } + + // Create a new refresh promise + refreshPromise = (async () => { + try { + isRefreshing.value = true; + + const response = await clientApi.post( + 'api/users/refresh', + { + refreshToken: refreshToken.value + }); + + updateTokens({ + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken + }); + + isRefreshing.value = false; + refreshPromise = null; + return true; + } catch (error) { + console.error('Token refresh failed:', error); + isRefreshing.value = false; + refreshPromise = null; + + // Only clear tokens and session storage after a failed refresh attempt + cleanTokens(); + + // Force a redirect to the login page + await router.push('/login'); + + throw error; + } + })(); + + return refreshPromise; } // Function to check if token needs refresh async function ensureValidToken() { - if (isTokenExpired(accessToken.value)) { - await refresh(); + if (isTokenExpiringSoon(accessToken.value)) { + // Start the refresh process without waiting for it to complete + refresh().catch(error => { + console.error('Error during token refresh:', error); + }); } } @@ -142,6 +174,7 @@ export const useAuthStore = defineStore( refreshToken, isAuthenticated, userId, + isRefreshing, login, loginWithGoogle, loginWithFacebook, diff --git a/frontend/src/stores/creatorProfileStore.js b/frontend/src/stores/creatorProfileStore.js index 30e912a..fc74a2a 100644 --- a/frontend/src/stores/creatorProfileStore.js +++ b/frontend/src/stores/creatorProfileStore.js @@ -22,7 +22,7 @@ export const useCreatorProfileStore = defineStore( } else { await router.push('/'); } - } else { + } else if (!authStore.isRefreshing) { value.value = undefined; } } diff --git a/frontend/src/stores/userProfileStore.js b/frontend/src/stores/userProfileStore.js index c469f70..929ace1 100644 --- a/frontend/src/stores/userProfileStore.js +++ b/frontend/src/stores/userProfileStore.js @@ -15,7 +15,7 @@ export const useUserProfileStore = defineStore( async (newValue) => { if (newValue) { await fetchCurrentUserProfile() - } else { + } else if (!authStore.isRefreshing) { value.value = undefined } }) @@ -59,7 +59,7 @@ export const useUserProfileStore = defineStore( const userResponse = await client.get("/api/users/profile"); value.value = userResponse.data } catch (error) { - value.value = undefined; + console.error(error) } }