diff --git a/AceJobAgency.client/src/components/LoginView.tsx b/AceJobAgency.client/src/components/LoginView.tsx
index 3090229..3092230 100644
--- a/AceJobAgency.client/src/components/LoginView.tsx
+++ b/AceJobAgency.client/src/components/LoginView.tsx
@@ -1,12 +1,36 @@
-import { Input, Checkbox, Button, Link } from "@heroui/react";
+import {
+ Input,
+ Checkbox,
+ Button,
+ Link,
+ useDisclosure,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@heroui/react";
import { IconMail, IconLock } from "@tabler/icons-react";
import { useState } from "react";
import { toast } from "react-toastify";
import http, { login } from "../http";
+import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
+
+export const checkTwoFactorStatus = async (email: string) => {
+ try {
+ const answer = await http.post("/User/has-2fa", {
+ email: email,
+ });
+ return answer.data.enabled as boolean;
+ } catch {
+ return false;
+ }
+};
export default function LoginView({ onSignup }: { onSignup: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
+ const { isOpen, onOpen, onOpenChange } = useDisclosure();
const validateFields = () => {
if (!email || !password) {
@@ -16,9 +40,22 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
return true;
};
- const handleLogin = async () => {
+ const checkFor2FA = async () => {
if (!validateFields()) return;
+ checkTwoFactorStatus(email)
+ .then((answer) => {
+ if (answer) {
+ onOpen();
+ } else {
+ handleLogin();
+ }
+ })
+ .catch(() => {
+ toast.error("Something went wrong! Please try again.");
+ });
+ };
+ const handleLogin = async () => {
const loginRequest = {
email,
password,
@@ -79,7 +116,7 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
-
+
+
+ Two-Factor Authentication
+
+
+
+
+
+
);
}
diff --git a/AceJobAgency.client/src/components/Manage2FAView.tsx b/AceJobAgency.client/src/components/Manage2FAView.tsx
new file mode 100644
index 0000000..08a039b
--- /dev/null
+++ b/AceJobAgency.client/src/components/Manage2FAView.tsx
@@ -0,0 +1,215 @@
+import { useEffect, useState } from "react";
+import { Button, Image, Input } from "@heroui/react";
+import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
+import { checkTwoFactorStatus } from "./LoginView";
+import http from "../http";
+import { toast } from "react-toastify";
+import { UserProfile } from "../models/user-profile";
+
+export default function Manage2FAView({
+ onClose,
+ userProfile,
+}: {
+ onClose: () => void;
+ userProfile: UserProfile;
+}) {
+ const [setup2FAStepperCount, setSetup2FAStepperCount] = useState(0);
+ const [disable2FAStepperCount, setDisable2FAStepperCount] = useState(0);
+ const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false);
+ const [setupQRBase64, setSetupQRBase64] = useState("");
+ const [setupBase32Secret, setSetupBase32Secret] = useState("");
+ const [userPassword, setUserPassword] = useState("");
+
+ const disableTwoFactor = async () => {
+ http
+ .post("/User/disable-2fa", {
+ id: userProfile.id,
+ })
+ .then(() => {
+ setDisable2FAStepperCount(2);
+ });
+ };
+
+ const verifyAccount = () => {
+ if (userPassword.length === 0) {
+ return;
+ }
+ http
+ .post("/User/login", {
+ verify: true,
+ email: userProfile.email,
+ password: userPassword,
+ })
+ .then(() => {
+ disableTwoFactor();
+ })
+ .catch(() => {
+ toast.error("Invalid password");
+ });
+ };
+
+ const enableTwoFactor = () => {
+ http.post("/User/enable-2fa").then((response) => {
+ setSetupQRBase64(response.data.qrCodeUrl);
+ setSetupBase32Secret(response.data.secret);
+ setSetup2FAStepperCount(1);
+ });
+ };
+
+ useEffect(() => {
+ checkTwoFactorStatus(userProfile.email)
+ .then((answer) => {
+ setIsTwoFactorEnabled(answer);
+ })
+ .catch(() => {
+ toast.error("Something went wrong. Please try again.");
+ });
+ }, []);
+
+ return (
+
+ {userProfile && (
+ <>
+ {!isTwoFactorEnabled && (
+
+ {setup2FAStepperCount === 0 && (
+
+
+ This setup will guide you through the enabling of
+ Two-Factors Authorization (2FA).
+
+
{
+ enableTwoFactor();
+ }}
+ >
+ Continue
+
+
+ )}
+ {setup2FAStepperCount === 1 && (
+
+
+ Please scan the QR code below using an authenticator app of
+ your choice.
+
+ {setupQRBase64 && (
+
+ )}
+
Or alternatively, manually enter the secret in the app:
+
+
+ {
+ setSetup2FAStepperCount(2);
+ }}
+ >
+ Continue
+
+
+
+ )}
+ {setup2FAStepperCount === 2 && (
+
+
Let's give it a try.
+
{
+ setSetup2FAStepperCount(3);
+ }}
+ />
+
+ {
+ setSetup2FAStepperCount(3);
+ }}
+ >
+ Skip
+
+ {
+ enableTwoFactor();
+ }}
+ >
+ Setup again
+
+
+
+ )}
+ {setup2FAStepperCount === 3 && (
+
+
+ All set! You will be asked to provide the passcode next time
+ you log in.
+
+
+ Finish
+
+
+ )}
+
+ )}
+ {isTwoFactorEnabled && (
+
+ {disable2FAStepperCount === 0 && (
+
+
+ Are you sure you want to disable Two-Factors Authorization
+ (2FA)?
+
+
+ {
+ setDisable2FAStepperCount(1);
+ }}
+ >
+ Confirm
+
+
+ Cancel
+
+
+
+ )}
+ {disable2FAStepperCount === 1 && (
+
+
Let's verify that it's you.
+
+
+ {
+ verifyAccount();
+ }}
+ >
+ Continue
+
+
+
+ )}
+ {disable2FAStepperCount === 2 && (
+
+
2FA has been disabled.
+
+ Finish
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/AceJobAgency.client/src/components/TwoFactorAuthenticationModule.tsx b/AceJobAgency.client/src/components/TwoFactorAuthenticationModule.tsx
new file mode 100644
index 0000000..35f5026
--- /dev/null
+++ b/AceJobAgency.client/src/components/TwoFactorAuthenticationModule.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+import http from "../http";
+import { toast } from "react-toastify";
+import { InputOtp } from "@heroui/react";
+
+export default function TwoFactorsAuthenticationModule({
+ email,
+ onTwoFactorSuccess,
+}: {
+ email: string;
+ onTwoFactorSuccess: () => void;
+}) {
+ const [twoFactorToken, setTwoFactorToken] = useState("");
+ const [twoFactorVerifying, setTwoFactorVerifying] = useState(false);
+ useEffect(() => {
+ if (!(twoFactorToken.length == 6 && !twoFactorVerifying)) {
+ return;
+ }
+
+ setTwoFactorVerifying(true);
+ http
+ .post("/User/verify-2fa", {
+ email: email,
+ token: twoFactorToken,
+ })
+ .then(() => {
+ onTwoFactorSuccess();
+ })
+ .catch((error) => {
+ toast.error(error);
+ })
+ .finally(() => {
+ setTwoFactorToken("");
+ setTwoFactorVerifying(false);
+ });
+ }, [twoFactorToken]);
+ return (
+
+
+
+ Please enter the 6 digits passcode from your authenticator app.
+
+
+ );
+}
diff --git a/AceJobAgency.client/src/pages/MemberPage.tsx b/AceJobAgency.client/src/pages/MemberPage.tsx
index 21c97c2..ac2bb3c 100644
--- a/AceJobAgency.client/src/pages/MemberPage.tsx
+++ b/AceJobAgency.client/src/pages/MemberPage.tsx
@@ -19,11 +19,22 @@ import remarkGfm from "remark-gfm";
import { IconDownload, IconEdit, IconUpload } from "@tabler/icons-react";
import { toast } from "react-toastify";
import ChangePasswordView from "../components/ChangePasswordView";
+import Manage2FAView from "../components/Manage2FAView";
export default function MemberPage() {
const accessToken = getAccessToken();
const navigate = useNavigate();
- const { isOpen, onOpen, onOpenChange } = useDisclosure();
+ const {
+ isOpen: changePasswordModalIsOpen,
+ onOpen: changePasswordModalOnOpen,
+ onOpenChange: changePasswordModalOnOpenChange,
+ } = useDisclosure();
+
+ const {
+ isOpen: twoFactorAuthModalIsOpen,
+ onOpen: twoFactorAuthModalOnOpen,
+ onOpenChange: twoFactorAuthModalOnOpenChange,
+ } = useDisclosure();
const [userProfile, setUserProfile] = useState(null);
@@ -185,13 +196,29 @@ export default function MemberPage() {
Log out
-
- Change password
-
+
+
+ Manage 2FA
+
+
+ Change password
+
+
)}
-
+
{(onClose) => (
<>
@@ -204,6 +231,28 @@ export default function MemberPage() {
)}
+
+
+
+ {(on2FAClose) => (
+ <>
+
+
+ {userProfile && (
+
+ )}
+
+
+ >
+ )}
+
+
);
}
diff --git a/AceJobAgency/AceJobAgency.csproj b/AceJobAgency/AceJobAgency.csproj
index a273417..3c24c91 100644
--- a/AceJobAgency/AceJobAgency.csproj
+++ b/AceJobAgency/AceJobAgency.csproj
@@ -14,7 +14,9 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/AceJobAgency/Controllers/UserController.cs b/AceJobAgency/Controllers/UserController.cs
index 731ca1e..b4f9617 100644
--- a/AceJobAgency/Controllers/UserController.cs
+++ b/AceJobAgency/Controllers/UserController.cs
@@ -6,7 +6,10 @@ using AceJobAgency.Entities;
using AceJobAgency.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
+using OtpNet;
+using QRCoder;
namespace AceJobAgency.Controllers
{
@@ -102,6 +105,11 @@ namespace AceJobAgency.Controllers
user.LockoutEndTime = null;
_context.SaveChanges();
+ if (request.Verify)
+ {
+ return Ok();
+ }
+
var token = GenerateJwtToken(user);
new ActivityLogController(_context).LogUserActivity(user.Id, "Login successful", ipAddress);
return Ok(new { token });
@@ -337,10 +345,113 @@ namespace AceJobAgency.Controllers
new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume successful", ipAddress);
return new FileStreamResult(fileStream, mimeType) { FileDownloadName = user.ResumeName };
}
+
+ private string GenerateBase32Secret()
+ {
+ var bytes = KeyGeneration.GenerateRandomKey(20);
+ var base32Secret = Base32Encoding.ToString(bytes);
+ return base32Secret;
+ }
+
+ // Generate a QR code as a data URL
+ private string GenerateQrCode(string otpauthUrl)
+ {
+ using var qrGenerator = new QRCodeGenerator();
+ using var qrCodeData = qrGenerator.CreateQrCode(otpauthUrl, QRCodeGenerator.ECCLevel.Q);
+ using var qrCode = new Base64QRCode(qrCodeData);
+ var qrCodeImage = qrCode.GetGraphic(20);
+ return $"data:image/png;base64,{qrCodeImage}";
+ }
+
+ [Authorize]
+ [HttpPost("enable-2fa")]
+ public async Task Enable2FA()
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive == 1);
+ if (user == null)
+ {
+ return NotFound("User not found");
+ }
+
+ var base32Secret = GenerateBase32Secret();
+ user.Secret = base32Secret;
+ _context.Users.Update(user);
+ await _context.SaveChangesAsync();
+
+ var totp = new Totp(Base32Encoding.ToBytes(base32Secret));
+ var uriString = new OtpUri(OtpType.Totp, base32Secret, user.Email, "Ace Job Agency").ToString();
+ var qrCodeUrl = GenerateQrCode(uriString);
+
+ return Ok(new
+ {
+ qrCodeUrl,
+ secret = base32Secret
+ });
+ }
+
+ [Authorize]
+ [HttpPost("disable-2fa")]
+ public async Task Disable2FA()
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive == 1);
+ if (user == null)
+ {
+ return NotFound("User not found");
+ }
+
+ user.Secret = null;
+ _context.Users.Update(user);
+ await _context.SaveChangesAsync();
+
+ return Ok(new { status = "success", message = "2FA disabled" });
+ }
+
+ [HttpPost("verify-2fa")]
+ public async Task Verify2FA([FromBody] Verify2FaRequest request)
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var user = await _context.Users.FirstOrDefaultAsync(u => u.Email == request.Email && u.IsActive == 1);
+ if (user == null)
+ {
+ return NotFound("User not found");
+ }
+
+ if (string.IsNullOrEmpty(user.Secret))
+ {
+ return BadRequest("2FA is not enabled for this user");
+ }
+
+ var totp = new Totp(Base32Encoding.ToBytes(user.Secret));
+ var isValid = totp.VerifyTotp(request.Token, out _, VerificationWindow.RfcSpecifiedNetworkDelay);
+
+ if (isValid)
+ {
+ return Ok(new { status = "success", message = "Authentication successful" });
+ }
+ else
+ {
+ return Unauthorized(new { status = "fail", message = "Authentication failed" });
+ }
+ }
+
+ [HttpPost("has-2fa")]
+ public async Task Has2FA([FromBody] Has2FaRequest request)
+ {
+ var user = await _context.Users.FirstOrDefaultAsync(u => u.Email == request.Email && u.IsActive == 1);
+ if (user == null)
+ {
+ return NotFound("User not found");
+ }
+
+ return Ok(new { status = "success", enabled = !string.IsNullOrEmpty(user.Secret) });
+ }
}
public class LoginRequest
{
+ public bool Verify { get; set; } = false;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -350,4 +461,15 @@ namespace AceJobAgency.Controllers
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
+
+ public class Verify2FaRequest
+ {
+ public string Email { get; set; } = string.Empty;
+ public string Token { get; set; } = string.Empty;
+ }
+
+ public class Has2FaRequest
+ {
+ public string Email { get; set; }
+ }
}
\ No newline at end of file
diff --git a/AceJobAgency/Entities/User.cs b/AceJobAgency/Entities/User.cs
index 638f0df..0163b09 100644
--- a/AceJobAgency/Entities/User.cs
+++ b/AceJobAgency/Entities/User.cs
@@ -65,5 +65,8 @@ namespace AceJobAgency.Entities
[DataType(DataType.Password)]
[MaxLength(128)]
public string PreviousPassword2 { get; set; } = string.Empty;
+
+ [MaxLength(128)]
+ public string? Secret { get; set; }
}
}
\ No newline at end of file
diff --git a/AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs b/AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs
new file mode 100644
index 0000000..4b062e2
--- /dev/null
+++ b/AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs
@@ -0,0 +1,143 @@
+//
+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("20250211022931_Added2FASecret")]
+ partial class Added2FASecret
+ {
+ ///
+ 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("PreviousPassword1")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("PreviousPassword2")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("ResumeName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Secret")
+ .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/20250211022931_Added2FASecret.cs b/AceJobAgency/Migrations/20250211022931_Added2FASecret.cs
new file mode 100644
index 0000000..96ae932
--- /dev/null
+++ b/AceJobAgency/Migrations/20250211022931_Added2FASecret.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace AceJobAgency.Migrations
+{
+ ///
+ public partial class Added2FASecret : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Secret",
+ table: "Users",
+ type: "varchar(128)",
+ maxLength: 128,
+ nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Secret",
+ table: "Users");
+ }
+ }
+}
diff --git a/AceJobAgency/Migrations/DataContextModelSnapshot.cs b/AceJobAgency/Migrations/DataContextModelSnapshot.cs
index 828df83..8b53475 100644
--- a/AceJobAgency/Migrations/DataContextModelSnapshot.cs
+++ b/AceJobAgency/Migrations/DataContextModelSnapshot.cs
@@ -118,6 +118,10 @@ namespace AceJobAgency.Migrations
.HasMaxLength(128)
.HasColumnType("varchar(128)");
+ b.Property("Secret")
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
b.Property("UpdatedAt")
.HasColumnType("datetime(6)");