diff --git a/AceJobAgency.client/src/components/LoginView.tsx b/AceJobAgency.client/src/components/LoginView.tsx index 4860cd9..bcb3933 100644 --- a/AceJobAgency.client/src/components/LoginView.tsx +++ b/AceJobAgency.client/src/components/LoginView.tsx @@ -27,8 +27,8 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) { try { const response = await http.post("/User/login", loginRequest); - if (response.status !== 200) { - throw new Error("Login failed"); + if (response.status === 401) { + throw new Error("Invalid email or password."); } const { token } = response.data; diff --git a/AceJobAgency.client/src/components/SessionTimeout.tsx b/AceJobAgency.client/src/components/SessionTimeout.tsx index 4f333f0..73fe6f3 100644 --- a/AceJobAgency.client/src/components/SessionTimeout.tsx +++ b/AceJobAgency.client/src/components/SessionTimeout.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import { ToastContainer, toast } from "react-toastify"; +import { toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; interface SessionTimeoutProps { @@ -47,11 +47,7 @@ const SessionTimeout: React.FC = ({ return () => clearInterval(interval); }, [lastActivityTime, timeout, onLogout, notified]); - return ( - <> - - - ); + return <>; }; export default SessionTimeout; diff --git a/AceJobAgency.client/src/http.ts b/AceJobAgency.client/src/http.ts index 1578e84..6caf71c 100644 --- a/AceJobAgency.client/src/http.ts +++ b/AceJobAgency.client/src/http.ts @@ -33,10 +33,10 @@ http.interceptors.response.use( function (error) { // Any status codes that falls outside the range of 2xx cause this function to trigger // Do something with response error - if (error.response.status === 401 || error.response.status === 403) { - localStorage.clear(); - window.location.assign("/error"); - } + // if (error.response.status === 401 || error.response.status === 403) { + // localStorage.clear(); + // window.location.assign("/error"); + // } return Promise.reject(error); } ); diff --git a/AceJobAgency/Controllers/UserController.cs b/AceJobAgency/Controllers/UserController.cs index cd6a3e1..16ad8ba 100644 --- a/AceJobAgency/Controllers/UserController.cs +++ b/AceJobAgency/Controllers/UserController.cs @@ -70,11 +70,33 @@ namespace AceJobAgency.Controllers public IActionResult Login([FromBody] LoginRequest request) { var user = _context.Users.FirstOrDefault(u => u.Email == request.Email && u.IsActive == 1); - if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password, user.Password)) + if (user == null) { return Unauthorized("Invalid email or password."); } + if (user.IsLockedOut && user.LockoutEndTime > DateTime.Now) + { + return Unauthorized("Account is locked. Try again later."); + } + + if (!BCrypt.Net.BCrypt.Verify(request.Password, user.Password)) + { + user.FailedLoginAttempts++; + if (user.FailedLoginAttempts >= 5) + { + user.IsLockedOut = true; + user.LockoutEndTime = DateTime.Now.AddMinutes(1); + } + _context.SaveChanges(); + return Unauthorized("Invalid email or password."); + } + + user.FailedLoginAttempts = 0; + user.IsLockedOut = false; + user.LockoutEndTime = null; + _context.SaveChanges(); + var token = GenerateJwtToken(user); return Ok(new { token }); } @@ -184,7 +206,7 @@ namespace AceJobAgency.Controllers new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Email, user.Email) ]), - Expires = DateTime.UtcNow.AddHours(2), + Expires = DateTime.UtcNow.AddMinutes(15), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; diff --git a/AceJobAgency/Entities/User.cs b/AceJobAgency/Entities/User.cs index 3db6b60..45c0e6f 100644 --- a/AceJobAgency/Entities/User.cs +++ b/AceJobAgency/Entities/User.cs @@ -50,5 +50,12 @@ namespace AceJobAgency.Entities [DataType(DataType.Date)] public DateTime UpdatedAt { get; set; } = DateTime.Now; + + public int FailedLoginAttempts { get; set; } = 0; + + public bool IsLockedOut { get; set; } = false; + + [DataType(DataType.DateTime)] + public DateTime? LockoutEndTime { get; set; } } } \ No newline at end of file diff --git a/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.Designer.cs b/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.Designer.cs new file mode 100644 index 0000000..9a993cd --- /dev/null +++ b/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using AceJobAgency.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AceJobAgency.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250209045614_AddedAccountLockoutFields")] + partial class AddedAccountLockoutFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("AceJobAgency.Entities.User", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("varchar(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("FailedLoginAttempts") + .HasColumnType("int"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("int"); + + b.Property("IsLockedOut") + .HasColumnType("tinyint(1)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LockoutEndTime") + .HasColumnType("datetime(6)"); + + b.Property("NationalRegistrationIdentityCardNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("ResumeName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WhoAmI") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.cs b/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.cs new file mode 100644 index 0000000..1328ac8 --- /dev/null +++ b/AceJobAgency/Migrations/20250209045614_AddedAccountLockoutFields.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AceJobAgency.Migrations +{ + /// + public partial class AddedAccountLockoutFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FailedLoginAttempts", + table: "Users", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "IsLockedOut", + table: "Users", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LockoutEndTime", + table: "Users", + type: "datetime(6)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FailedLoginAttempts", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsLockedOut", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LockoutEndTime", + table: "Users"); + } + } +} diff --git a/AceJobAgency/Migrations/DataContextModelSnapshot.cs b/AceJobAgency/Migrations/DataContextModelSnapshot.cs index f7868ce..16f9639 100644 --- a/AceJobAgency/Migrations/DataContextModelSnapshot.cs +++ b/AceJobAgency/Migrations/DataContextModelSnapshot.cs @@ -39,6 +39,9 @@ namespace AceJobAgency.Migrations .HasMaxLength(128) .HasColumnType("varchar(128)"); + b.Property("FailedLoginAttempts") + .HasColumnType("int"); + b.Property("FirstName") .IsRequired() .HasMaxLength(50) @@ -50,11 +53,17 @@ namespace AceJobAgency.Migrations b.Property("IsActive") .HasColumnType("int"); + b.Property("IsLockedOut") + .HasColumnType("tinyint(1)"); + b.Property("LastName") .IsRequired() .HasMaxLength(50) .HasColumnType("varchar(50)"); + b.Property("LockoutEndTime") + .HasColumnType("datetime(6)"); + b.Property("NationalRegistrationIdentityCardNumber") .IsRequired() .HasMaxLength(255)