From e0788cbeb5d615498151bf94aa19db96ca101771 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Wed, 12 Feb 2025 13:39:14 +0800 Subject: [PATCH] 2fa --- .../src/components/LoginView.tsx | 55 ++++- .../src/components/Manage2FAView.tsx | 215 ++++++++++++++++++ .../TwoFactorAuthenticationModule.tsx | 51 +++++ AceJobAgency.client/src/pages/MemberPage.tsx | 59 ++++- AceJobAgency/AceJobAgency.csproj | 2 + AceJobAgency/Controllers/UserController.cs | 122 ++++++++++ AceJobAgency/Entities/User.cs | 3 + .../20250211022931_Added2FASecret.Designer.cs | 143 ++++++++++++ .../20250211022931_Added2FASecret.cs | 30 +++ .../Migrations/DataContextModelSnapshot.cs | 4 + 10 files changed, 676 insertions(+), 8 deletions(-) create mode 100644 AceJobAgency.client/src/components/Manage2FAView.tsx create mode 100644 AceJobAgency.client/src/components/TwoFactorAuthenticationModule.tsx create mode 100644 AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs create mode 100644 AceJobAgency/Migrations/20250211022931_Added2FASecret.cs 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 }) {
-
@@ -89,6 +126,18 @@ 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). +

+ +
+ )} + {setup2FAStepperCount === 1 && ( +
+

+ Please scan the QR code below using an authenticator app of + your choice. +

+ {setupQRBase64 && ( + 2FA SETUP QR + )} +

Or alternatively, manually enter the secret in the app:

+ +
+ +
+
+ )} + {setup2FAStepperCount === 2 && ( +
+

Let's give it a try.

+ { + setSetup2FAStepperCount(3); + }} + /> +
+ + +
+
+ )} + {setup2FAStepperCount === 3 && ( +
+

+ All set! You will be asked to provide the passcode next time + you log in. +

+
+ +
+
+ )} +
+ )} + {isTwoFactorEnabled && ( +
+ {disable2FAStepperCount === 0 && ( +
+

+ Are you sure you want to disable Two-Factors Authorization + (2FA)? +

+
+ + +
+
+ )} + {disable2FAStepperCount === 1 && ( +
+

Let's verify that it's you.

+ +
+ +
+
+ )} + {disable2FAStepperCount === 2 && ( +
+

2FA has been disabled.

+
+ +
+
+ )} +
+ )} + + )} +
+ ); +} 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() { - +
+ + +
)} - + {(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)");