diff --git a/API/Controllers/AccountsController.cs b/API/Controllers/AccountsController.cs index d2ef892..bf460aa 100644 --- a/API/Controllers/AccountsController.cs +++ b/API/Controllers/AccountsController.cs @@ -4,7 +4,6 @@ using API.DTOs.Requests; using API.DTOs.Responses; using API.Entities; -using API.Extensions; using API.Services; using MapsterMapper; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -23,15 +22,17 @@ public class AccountsController : ControllerBase private readonly ITokenService _tokenService; private readonly IMapper _mapper; private readonly IImageService _imageService; + private readonly IUserLoginService _loginService; private readonly AwsConfig _awsConfig; public AccountsController(UserManager userManager, ITokenService tokenService, IMapper mapper, - IImageService imageService, IOptions awsConfig) + IImageService imageService, IOptions awsConfig, IUserLoginService loginService) { _userManager = userManager; _tokenService = tokenService; _mapper = mapper; _imageService = imageService; + _loginService = loginService; _awsConfig = awsConfig.Value; } @@ -104,103 +105,66 @@ public async Task RegisterAdmin(RegisterDto model) public async Task> Login(LoginDto userLoginDto) { var user = await _userManager.FindByNameAsync(userLoginDto.UserName.Trim()); - - if (user is null) - return NotFound(); + if (user is null) return NotFound(); var passwordValid = await _userManager.CheckPasswordAsync(user, userLoginDto.Password); + if (!passwordValid) return BadRequest(); - if (!passwordValid) - return BadRequest(); + var userLogin = await _loginService.LoginAsync(user); - var roles = await _userManager.GetRolesAsync(user); + await _loginService.TryDeleteLoginRecord(Request.Cookies["refreshToken"]); - var refreshToken = _tokenService.GenerateRefreshToken(); - user.RefreshToken = refreshToken; - await _userManager.UpdateAsync(user); - _tokenService.SetRefreshTokenInCookies(refreshToken, Response); + var roles = await _userManager.GetRolesAsync(user); - var accountDetails = _mapper.Map((user, roles.AsEnumerable())); - var accessToken = _tokenService.GenerateAccessToken(user, roles); + _tokenService.SetRefreshTokenInCookies(_mapper.Map(userLogin), Response); return new AuthResult { - AccessToken = accessToken.AccessToken, - AccountDetails = accountDetails + AccessToken = _tokenService.GenerateAccessToken(user, roles).AccessToken, + AccountDetails = _mapper.Map((user, roles.AsEnumerable())) }; } [HttpPost("register")] - public async Task> Register(RegisterDto model) + public async Task> Register(RegisterDto dto) { - var user = new User() - { - UserName = model.UserName, - Email = model.Email, - SecurityStamp = Guid.NewGuid().ToString(), - }; - - var refreshToken = _tokenService.GenerateRefreshToken(); - user.RefreshToken = refreshToken; - - var result = await _userManager.CreateAsync(user, model.Password); - if (!result.Succeeded) + var userLogin = await _loginService.RegisterAndLoginAsync(dto.UserName, dto.Email, dto.Password); + if (userLogin == null) return BadRequest(); - _tokenService.SetRefreshTokenInCookies(refreshToken, Response); - await _userManager.AddToRoleAsync(user, "User"); - var roles = new List() { "User" }; - var createdUser = await _userManager.FindByNameAsync(user.UserName); + await _userManager.AddToRolesAsync(userLogin.User, roles); - var accountDetails = _mapper.Map((createdUser, roles.AsEnumerable())); - var accessToken = _tokenService.GenerateAccessToken(createdUser!, roles); + _tokenService.SetRefreshTokenInCookies(_mapper.Map(userLogin), Response); + + await _loginService.TryDeleteLoginRecord(Request.Cookies["refreshToken"]); return new AuthResult { - AccessToken = accessToken.AccessToken, - AccountDetails = accountDetails + AccessToken = _tokenService.GenerateAccessToken(userLogin.User, roles).AccessToken, + AccountDetails = _mapper.Map((userLogin.User, roles.AsEnumerable())) }; } [HttpGet("renew-tokens")] public async Task> Refresh() { - var refreshToken = Request.Cookies["refreshToken"]; - if (refreshToken is null) + var userLogin = await _loginService.RenewToken(Request.Cookies["refreshToken"]); + if (userLogin == null) return BadRequest(); - var user = await _userManager.FindByRefreshTokenAsync(refreshToken); - if (user is null) - return BadRequest(); - - if (user.RefreshToken.ExpiredAt < DateTime.UtcNow) - return BadRequest(); + _tokenService.SetRefreshTokenInCookies(_mapper.Map(userLogin), Response); - var newRefreshToken = _tokenService.GenerateRefreshToken(); - user.RefreshToken = newRefreshToken; - await _userManager.UpdateAsync(user); - _tokenService.SetRefreshTokenInCookies(newRefreshToken, Response); - - var roles = await _userManager.GetRolesAsync(user); - - return _tokenService.GenerateAccessToken(user, roles); + return _tokenService.GenerateAccessToken(userLogin.User, await _userManager.GetRolesAsync(userLogin.User)); } [HttpGet("logout")] public async Task Logout() { - var refreshToken = Request.Cookies["refreshToken"]; - if (refreshToken is null) - return Ok(); - - var user = await _userManager.FindByRefreshTokenAsync(refreshToken); - if (user is null) - return Ok(); + await _loginService.TryDeleteLoginRecord(Request.Cookies["refreshToken"]); _tokenService.DeleteRefreshTokenInCookies(Response); - user.RefreshToken.Token = null; - await _userManager.UpdateAsync(user); + return Ok(); } } \ No newline at end of file diff --git a/API/Data/Migrations/20231122085703_AddUserSession.Designer.cs b/API/Data/Migrations/20231122085703_AddUserSession.Designer.cs new file mode 100644 index 0000000..0b70b06 --- /dev/null +++ b/API/Data/Migrations/20231122085703_AddUserSession.Designer.cs @@ -0,0 +1,505 @@ +// +using System; +using API.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 API.Data.Migrations +{ + [DbContext(typeof(StoreContext))] + [Migration("20231122085703_AddUserSession")] + partial class AddUserSession + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("API.Entities.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("API.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("UserId"); + + b.ToTable("CartItems"); + }); + + modelBuilder.Entity("API.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("API.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("double precision"); + + b.Property("QuantityInStock") + .HasColumnType("integer"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("API.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + 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("ProfilePicture") + .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("API.Entities.UserRole", 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); + }); + + modelBuilder.Entity("API.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshTokenCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenExpiredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RefreshToken") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("API.Entities.WishList", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.HasKey("UserId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("WishLists"); + }); + + 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("API.Entities.CartItem", b => + { + b.HasOne("API.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Product", b => + { + b.HasOne("API.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("API.Entities.UserSession", b => + { + b.HasOne("API.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.WishList", b => + { + b.HasOne("API.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.UserRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("API.Entities.UserRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20231122085703_AddUserSession.cs b/API/Data/Migrations/20231122085703_AddUserSession.cs new file mode 100644 index 0000000..54174cb --- /dev/null +++ b/API/Data/Migrations/20231122085703_AddUserSession.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AddUserSession : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_RefreshToken_Token", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "RefreshToken_CreatedAt", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "RefreshToken_ExpiredAt", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "RefreshToken_Token", + table: "AspNetUsers"); + + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + RefreshToken = table.Column(type: "text", nullable: false), + RefreshTokenCreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RefreshTokenExpiredAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_RefreshToken", + table: "UserSessions", + column: "RefreshToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSessions"); + + migrationBuilder.AddColumn( + name: "RefreshToken_CreatedAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RefreshToken_ExpiredAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RefreshToken_Token", + table: "AspNetUsers", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_RefreshToken_Token", + table: "AspNetUsers", + column: "RefreshToken_Token", + unique: true); + } + } +} diff --git a/API/Data/Migrations/StoreContextModelSnapshot.cs b/API/Data/Migrations/StoreContextModelSnapshot.cs index fecfc1e..aa446e5 100644 --- a/API/Data/Migrations/StoreContextModelSnapshot.cs +++ b/API/Data/Migrations/StoreContextModelSnapshot.cs @@ -229,6 +229,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetRoles", (string)null); }); + modelBuilder.Entity("API.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshTokenCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenExpiredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RefreshToken") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + modelBuilder.Entity("API.Entities.WishList", b => { b.Property("UserId") @@ -385,35 +416,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Category"); }); - modelBuilder.Entity("API.Entities.User", b => + modelBuilder.Entity("API.Entities.UserSession", b => { - b.OwnsOne("API.Entities.RefreshToken", "RefreshToken", b1 => - { - b1.Property("UserId") - .HasColumnType("integer"); - - b1.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b1.Property("ExpiredAt") - .HasColumnType("timestamp with time zone"); - - b1.Property("Token") - .HasColumnType("text"); - - b1.HasKey("UserId"); - - b1.HasIndex("Token") - .IsUnique(); - - b1.ToTable("AspNetUsers"); - - b1.WithOwner() - .HasForeignKey("UserId"); - }); - - b.Navigation("RefreshToken") + b.HasOne("API.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.Navigation("User"); }); modelBuilder.Entity("API.Entities.WishList", b => diff --git a/API/Data/StoreContext.cs b/API/Data/StoreContext.cs index 62ccd0b..3fc11bc 100644 --- a/API/Data/StoreContext.cs +++ b/API/Data/StoreContext.cs @@ -12,13 +12,9 @@ public StoreContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder builder) { - builder.Entity() - .OwnsOne(u => u.RefreshToken, ownedNavigationBuilder => - { - ownedNavigationBuilder - .HasIndex(r => r.Token) - .IsUnique(); - }); + builder.Entity() + .HasIndex(u => u.RefreshToken) + .IsUnique(); builder.Entity() .HasIndex(b => b.Name) @@ -39,4 +35,5 @@ protected override void OnModelCreating(ModelBuilder builder) public DbSet Brands => Set(); public DbSet WishLists => Set(); public DbSet CartItems => Set(); + public DbSet UserSessions => Set(); } \ No newline at end of file diff --git a/API/Entities/RefreshToken.cs b/API/Entities/RefreshToken.cs index 60e1894..a2de31a 100644 --- a/API/Entities/RefreshToken.cs +++ b/API/Entities/RefreshToken.cs @@ -1,11 +1,8 @@ -using Microsoft.EntityFrameworkCore; - namespace API.Entities; -[Owned] public class RefreshToken { - public string? Token { get; set; } - public DateTime? CreatedAt { get; set; } - public DateTime? ExpiredAt { get; set; } + public string Token { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime ExpiredAt { get; set; } } \ No newline at end of file diff --git a/API/Entities/User.cs b/API/Entities/User.cs index 69f4454..fbd0329 100644 --- a/API/Entities/User.cs +++ b/API/Entities/User.cs @@ -4,6 +4,5 @@ namespace API.Entities; public class User : IdentityUser { - public RefreshToken RefreshToken { get; set; } public string? ProfilePicture { get; set; } } \ No newline at end of file diff --git a/API/Entities/UserSession.cs b/API/Entities/UserSession.cs new file mode 100644 index 0000000..3d1fa24 --- /dev/null +++ b/API/Entities/UserSession.cs @@ -0,0 +1,12 @@ +namespace API.Entities; + +public class UserSession +{ + public int Id { get; set; } + public int UserId { get; set; } + public string RefreshToken { get; set; } + public DateTime RefreshTokenCreatedAt { get; set; } + public DateTime RefreshTokenExpiredAt { get; set; } + + public User User { get; set; } +} \ No newline at end of file diff --git a/API/Extensions/UserManagerExtensions.cs b/API/Extensions/UserManagerExtensions.cs deleted file mode 100644 index e5c0725..0000000 --- a/API/Extensions/UserManagerExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using API.Entities; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; - -namespace API.Extensions; - -public static class UserManagerExtensions -{ - public static async Task FindByRefreshTokenAsync(this UserManager um, string token) - { - return await um.Users.SingleOrDefaultAsync(x => x.RefreshToken.Token == token); - } -} \ No newline at end of file diff --git a/API/Mappings/RefreshTokenMapping.cs b/API/Mappings/RefreshTokenMapping.cs new file mode 100644 index 0000000..9ff4f9d --- /dev/null +++ b/API/Mappings/RefreshTokenMapping.cs @@ -0,0 +1,15 @@ +using API.Entities; +using Mapster; + +namespace API.Mappings; + +public class RefreshTokenMapping : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(dest => dest.Token, src => src.RefreshToken) + .Map(dest => dest.CreatedAt, src => src.RefreshTokenCreatedAt) + .Map(dest => dest.ExpiredAt, src => src.RefreshTokenExpiredAt); + } +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 8e5186e..c85e3ef 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -58,6 +58,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => { diff --git a/API/Services/IUserLoginService.cs b/API/Services/IUserLoginService.cs new file mode 100644 index 0000000..378ff0f --- /dev/null +++ b/API/Services/IUserLoginService.cs @@ -0,0 +1,14 @@ +using API.Entities; + +namespace API.Services; + +public interface IUserLoginService +{ + Task TryDeleteLoginRecord(string? refreshToken); + + Task LoginAsync(User user); + + Task RegisterAndLoginAsync(string username, string email, string password); + + Task RenewToken(string? currentToken); +} \ No newline at end of file diff --git a/API/Services/UserLoginService.cs b/API/Services/UserLoginService.cs new file mode 100644 index 0000000..a40e919 --- /dev/null +++ b/API/Services/UserLoginService.cs @@ -0,0 +1,112 @@ +using API.Data; +using API.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace API.Services; + +public class UserLoginService : IUserLoginService +{ + private readonly StoreContext _context; + private readonly ITokenService _tokenService; + private readonly UserManager _userManager; + + public UserLoginService(StoreContext context, ITokenService tokenService, UserManager userManager) + { + _context = context; + _tokenService = tokenService; + _userManager = userManager; + } + + public async Task RenewToken(string? currentToken) + { + if (currentToken == null) + return null; + + var userLogin = await _context.UserSessions + .Include(l => l.User) + .FirstOrDefaultAsync(l => l.RefreshToken == currentToken); + + if (userLogin == null) + return null; + + if (userLogin.RefreshTokenExpiredAt < DateTime.UtcNow) + { + _context.UserSessions.Remove(userLogin); + await _context.SaveChangesAsync(); + return null; + } + + var newRefreshToken = _tokenService.GenerateRefreshToken(); + userLogin.RefreshToken = newRefreshToken.Token; + userLogin.RefreshTokenCreatedAt = newRefreshToken.CreatedAt; + userLogin.RefreshTokenExpiredAt = newRefreshToken.ExpiredAt; + + _context.UserSessions.Update(userLogin); + await _context.SaveChangesAsync(); + + return userLogin; + } + + public async Task TryDeleteLoginRecord(string? refreshToken) + { + if (refreshToken != null) + { + var existing = await _context.UserSessions.FirstOrDefaultAsync(l => l.RefreshToken == refreshToken); + if (existing != null) + { + _context.UserSessions.Remove(existing); + await _context.SaveChangesAsync(); + } + } + } + + public async Task LoginAsync(User user) + { + var token = _tokenService.GenerateRefreshToken(); + + var userLogin = new UserSession + { + RefreshToken = token.Token, + RefreshTokenCreatedAt = token.CreatedAt, + RefreshTokenExpiredAt = token.ExpiredAt, + UserId = user.Id, + User = user + }; + + await _context.UserSessions.AddAsync(userLogin); + await _context.SaveChangesAsync(); + + return userLogin; + } + + public async Task RegisterAndLoginAsync(string username, string email, string password) + { + var user = new User() + { + UserName = username, + Email = email, + SecurityStamp = Guid.NewGuid().ToString(), + }; + + var userResult = await _userManager.CreateAsync(user, password); + if (!userResult.Succeeded) + return null; + + var refreshToken = _tokenService.GenerateRefreshToken(); + + var userLogin = new UserSession + { + RefreshToken = refreshToken.Token, + RefreshTokenCreatedAt = refreshToken.CreatedAt, + RefreshTokenExpiredAt = refreshToken.ExpiredAt, + UserId = user.Id, + User = user + }; + + await _context.UserSessions.AddAsync(userLogin); + await _context.SaveChangesAsync(); + + return userLogin; + } +} \ No newline at end of file diff --git a/Client/src/components/AppLogo.tsx b/Client/src/components/AppLogo.tsx index 88212f1..1c631cb 100644 --- a/Client/src/components/AppLogo.tsx +++ b/Client/src/components/AppLogo.tsx @@ -12,3 +12,14 @@ export const AppLogo = (props: any) => { ); }; + +export const AppLogoSlim = () => { + return ( + + + + ); +}; diff --git a/Client/src/components/Header.tsx b/Client/src/components/Header.tsx index 672fa1d..13395d3 100644 --- a/Client/src/components/Header.tsx +++ b/Client/src/components/Header.tsx @@ -25,7 +25,7 @@ import { FiShoppingCart } from 'react-icons/fi'; import { RxPerson } from 'react-icons/rx'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.tsx'; -import { AppLogo } from './AppLogo.tsx'; +import { AppLogo, AppLogoSlim } from './AppLogo.tsx'; import React, { useState } from 'react'; import { useCart } from '../hooks/queries/useCart.ts'; import { MdOutlineInventory2 } from 'react-icons/md'; @@ -69,7 +69,17 @@ const Header = () => { }} > - {(isMobile ? !searchBoxVisible : true) && ( + {isMobile ? ( + searchBoxVisible ? ( + + + + ) : ( + + + + ) + ) : ( diff --git a/Client/src/pages/User/MyAccount.tsx b/Client/src/pages/User/MyAccount.tsx index 520dfe4..6af2a88 100644 --- a/Client/src/pages/User/MyAccount.tsx +++ b/Client/src/pages/User/MyAccount.tsx @@ -5,6 +5,7 @@ import { Box, Button, Card, + Center, Heading, HStack, IconButton, @@ -12,11 +13,13 @@ import { Tag, Text, VStack, + Wrap, } from '@chakra-ui/react'; import { CheckIcon, EditIcon } from '@chakra-ui/icons'; import AntdSpin from '../../components/AntdSpin'; import { useUpdateProfilePicture } from '../../hooks/mutations/useUpdateProfilePicture.ts'; import { useRef } from 'react'; +import { Link } from 'react-router-dom'; const MyAccount = () => { const { data, isLoading, isError } = useMyAccount(); @@ -32,7 +35,16 @@ const MyAccount = () => { } if (isError) { - return

Error loading account settings

; + return ( +
+ +

Session expired. Please login again.

+ +
+
+ ); } if (!data) return null; @@ -80,11 +92,13 @@ const MyAccount = () => { {!(data.roles.length === 1 && data.roles.includes('User')) && ( Account type: - {data.roles.map((role) => ( - - {role} - - ))} + + {data.roles.map((role) => ( + + {role} + + ))} + )}