From 495fff3ac93ef8cb15acbabf03787a59320c705a Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 11 Feb 2025 01:17:05 +0800 Subject: [PATCH] User activity logs --- .../Controllers/ActivityLogController.cs | 27 ++++ AceJobAgency/Controllers/UserController.cs | 22 +++ AceJobAgency/Data/DataContext.cs | 1 + AceJobAgency/Entities/ActivityLog.cs | 25 ++++ .../20250210171437_AddedLogs.Designer.cs | 129 ++++++++++++++++++ .../Migrations/20250210171437_AddedLogs.cs | 42 ++++++ .../Migrations/DataContextModelSnapshot.cs | 29 ++++ 7 files changed, 275 insertions(+) create mode 100644 AceJobAgency/Controllers/ActivityLogController.cs create mode 100644 AceJobAgency/Entities/ActivityLog.cs create mode 100644 AceJobAgency/Migrations/20250210171437_AddedLogs.Designer.cs create mode 100644 AceJobAgency/Migrations/20250210171437_AddedLogs.cs diff --git a/AceJobAgency/Controllers/ActivityLogController.cs b/AceJobAgency/Controllers/ActivityLogController.cs new file mode 100644 index 0000000..4fc7b03 --- /dev/null +++ b/AceJobAgency/Controllers/ActivityLogController.cs @@ -0,0 +1,27 @@ +using AceJobAgency.Data; +using AceJobAgency.Entities; + +namespace AceJobAgency.Controllers; + +public class ActivityLogController +{ + private readonly DataContext _context; + + public ActivityLogController(DataContext context) + { + _context = context; + } + + public void LogUserActivity(string userId, string activity, string ipAddress) + { + var logEntry = new ActivityLog + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + Activity = activity, + IpAddress = ipAddress + }; + _context.ActivityLogs.Add(logEntry); + _context.SaveChanges(); + } +} \ No newline at end of file diff --git a/AceJobAgency/Controllers/UserController.cs b/AceJobAgency/Controllers/UserController.cs index 16ad8ba..bed6426 100644 --- a/AceJobAgency/Controllers/UserController.cs +++ b/AceJobAgency/Controllers/UserController.cs @@ -70,6 +70,8 @@ namespace AceJobAgency.Controllers public IActionResult Login([FromBody] LoginRequest request) { var user = _context.Users.FirstOrDefault(u => u.Email == request.Email && u.IsActive == 1); + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); + if (user == null) { return Unauthorized("Invalid email or password."); @@ -77,6 +79,7 @@ namespace AceJobAgency.Controllers if (user.IsLockedOut && user.LockoutEndTime > DateTime.Now) { + new ActivityLogController(_context).LogUserActivity(user.Id, "Login rejected: Locked out", ipAddress); return Unauthorized("Account is locked. Try again later."); } @@ -87,8 +90,10 @@ namespace AceJobAgency.Controllers { user.IsLockedOut = true; user.LockoutEndTime = DateTime.Now.AddMinutes(1); + new ActivityLogController(_context).LogUserActivity(user.Id, "Account locked due to 5 consecutive failed attempts.", ipAddress); } _context.SaveChanges(); + new ActivityLogController(_context).LogUserActivity(user.Id, "Login rejected: invalid password", ipAddress); return Unauthorized("Invalid email or password."); } @@ -98,6 +103,7 @@ namespace AceJobAgency.Controllers _context.SaveChanges(); var token = GenerateJwtToken(user); + new ActivityLogController(_context).LogUserActivity(user.Id, "Login successful", ipAddress); return Ok(new { token }); } @@ -125,6 +131,8 @@ namespace AceJobAgency.Controllers user.WhoAmI, user.ResumeName, }; + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); + new ActivityLogController(_context).LogUserActivity(user.Id, "Fetched user profile", ipAddress); return Ok(response); } @@ -147,6 +155,8 @@ namespace AceJobAgency.Controllers _context.Users.Update(user); await _context.SaveChangesAsync(); + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); + new ActivityLogController(_context).LogUserActivity(user.Id, "Updated user profile", ipAddress); return Ok(user); } @@ -154,6 +164,7 @@ namespace AceJobAgency.Controllers [HttpPut("change-password")] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var user = _context.Users.FirstOrDefault(u => u.Id == userId && u.IsActive == 1); if (user == null) @@ -163,11 +174,13 @@ namespace AceJobAgency.Controllers if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.Password)) { + new ActivityLogController(_context).LogUserActivity(user.Id, "Change password failed: Current password is incorrect", ipAddress); return BadRequest("Current password is incorrect."); } if (!AccountManagement.IsPasswordComplex(request.NewPassword)) { + new ActivityLogController(_context).LogUserActivity(user.Id, "Change password failed: Password not complex enough", ipAddress); return BadRequest("Password must be at least 12 characters long and include uppercase, lowercase, number, and special character."); } @@ -175,6 +188,7 @@ namespace AceJobAgency.Controllers user.UpdatedAt = DateTime.Now; _context.Users.Update(user); await _context.SaveChangesAsync(); + new ActivityLogController(_context).LogUserActivity(user.Id, "Change password successful", ipAddress); return Ok("Password updated successfully."); } @@ -263,6 +277,9 @@ namespace AceJobAgency.Controllers _context.Users.Update(user); await _context.SaveChangesAsync(); + + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); + new ActivityLogController(_context).LogUserActivity(user.Id, "Updated user profile", ipAddress); return Ok(new { ResumeName = user.ResumeName }); } @@ -277,9 +294,12 @@ namespace AceJobAgency.Controllers { return NotFound(); } + + var ipAddress = HttpContext.Connection.RemoteIpAddress!.ToString(); if (string.IsNullOrEmpty(user.ResumeName)) { + new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume failed: No resume found", ipAddress); return NotFound("No resume found."); } @@ -287,6 +307,7 @@ namespace AceJobAgency.Controllers if (!System.IO.File.Exists(filePath)) { + new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume failed: Resume file missing", ipAddress); return NotFound("Resume file not found."); } @@ -296,6 +317,7 @@ namespace AceJobAgency.Controllers else if (extension == ".doc" || extension == ".docx") mimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume successful", ipAddress); return new FileStreamResult(fileStream, mimeType) { FileDownloadName = user.ResumeName }; } } diff --git a/AceJobAgency/Data/DataContext.cs b/AceJobAgency/Data/DataContext.cs index a5d3c3b..fbec703 100644 --- a/AceJobAgency/Data/DataContext.cs +++ b/AceJobAgency/Data/DataContext.cs @@ -16,5 +16,6 @@ namespace AceJobAgency.Data } } public DbSet Users { get; set; } + public DbSet ActivityLogs { get; set; } } } diff --git a/AceJobAgency/Entities/ActivityLog.cs b/AceJobAgency/Entities/ActivityLog.cs new file mode 100644 index 0000000..ca8bcdd --- /dev/null +++ b/AceJobAgency/Entities/ActivityLog.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace AceJobAgency.Entities; + +public class ActivityLog +{ + [Key] + [MaxLength(36)] + public required string Id { get; set; } + + [Required] + [StringLength(36)] + public required string UserId { get; set; } + + [Required] + [MaxLength(255)] + public required string Activity { get; set; } + + [Required] + [StringLength(15)] + public required string IpAddress { get; set; } + + [DataType(DataType.Date)] + public DateTime CreatedAt { get; init; } = DateTime.Now; +} \ No newline at end of file diff --git a/AceJobAgency/Migrations/20250210171437_AddedLogs.Designer.cs b/AceJobAgency/Migrations/20250210171437_AddedLogs.Designer.cs new file mode 100644 index 0000000..333f7e8 --- /dev/null +++ b/AceJobAgency/Migrations/20250210171437_AddedLogs.Designer.cs @@ -0,0 +1,129 @@ +// +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("20250210171437_AddedLogs")] + partial class AddedLogs + { + /// + 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.ActivityLog", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("varchar(36)"); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("varchar(15)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + 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(2048) + .HasColumnType("varchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AceJobAgency/Migrations/20250210171437_AddedLogs.cs b/AceJobAgency/Migrations/20250210171437_AddedLogs.cs new file mode 100644 index 0000000..549efa9 --- /dev/null +++ b/AceJobAgency/Migrations/20250210171437_AddedLogs.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AceJobAgency.Migrations +{ + /// + public partial class AddedLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActivityLogs", + columns: table => new + { + Id = table.Column(type: "varchar(36)", maxLength: 36, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + UserId = table.Column(type: "varchar(36)", maxLength: 36, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Activity = table.Column(type: "varchar(255)", maxLength: 255, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IpAddress = table.Column(type: "varchar(15)", maxLength: 15, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActivityLogs", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActivityLogs"); + } + } +} diff --git a/AceJobAgency/Migrations/DataContextModelSnapshot.cs b/AceJobAgency/Migrations/DataContextModelSnapshot.cs index f7d244f..720725e 100644 --- a/AceJobAgency/Migrations/DataContextModelSnapshot.cs +++ b/AceJobAgency/Migrations/DataContextModelSnapshot.cs @@ -22,6 +22,35 @@ namespace AceJobAgency.Migrations MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + modelBuilder.Entity("AceJobAgency.Entities.ActivityLog", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("varchar(36)"); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("varchar(15)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + modelBuilder.Entity("AceJobAgency.Entities.User", b => { b.Property("Id")