This commit is contained in:
2025-02-12 13:39:14 +08:00
parent 44ab95b64d
commit e0788cbeb5
10 changed files with 676 additions and 8 deletions

View File

@@ -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 }) {
</Link>
</div>
<div className="flex flex-col gap-4 w-full">
<Button color="primary" className="w-full" onPress={handleLogin}>
<Button color="primary" className="w-full" onPress={checkFor2FA}>
Login
</Button>
<div className="flex flex-row gap-2 w-full justify-center *:my-auto">
@@ -89,6 +126,18 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
</Link>
</div>
</div>
<Modal size="lg" isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Two-Factor Authentication</ModalHeader>
<ModalBody>
<TwoFactorAuthenticationModule
email={email}
onTwoFactorSuccess={handleLogin}
/>
</ModalBody>
<ModalFooter></ModalFooter>
</ModalContent>
</Modal>
</div>
);
}

View File

@@ -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 (
<div>
{userProfile && (
<>
{!isTwoFactorEnabled && (
<div>
{setup2FAStepperCount === 0 && (
<div className="flex flex-col gap-4">
<p>
This setup will guide you through the enabling of
Two-Factors Authorization (2FA).
</p>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Continue
</Button>
</div>
)}
{setup2FAStepperCount === 1 && (
<div className="flex flex-col gap-4">
<p>
Please scan the QR code below using an authenticator app of
your choice.
</p>
{setupQRBase64 && (
<Image
className="shadow-medium"
src={setupQRBase64}
alt="2FA SETUP QR"
/>
)}
<p>Or alternatively, manually enter the secret in the app:</p>
<Input value={setupBase32Secret} readOnly />
<div className="w-full flex flex-row justify-end">
<Button
onPress={() => {
setSetup2FAStepperCount(2);
}}
>
Continue
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 2 && (
<div className="flex flex-col gap-4">
<p>Let's give it a try.</p>
<TwoFactorAuthenticationModule
email={userProfile.email}
onTwoFactorSuccess={() => {
setSetup2FAStepperCount(3);
}}
/>
<div className="w-full flex flex-row justify-end">
<Button
variant="light"
onPress={() => {
setSetup2FAStepperCount(3);
}}
>
Skip
</Button>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Setup again
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 3 && (
<div className="flex flex-col gap-4">
<p>
All set! You will be asked to provide the passcode next time
you log in.
</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
{isTwoFactorEnabled && (
<div>
{disable2FAStepperCount === 0 && (
<div className="flex flex-col gap-4 w-full">
<p>
Are you sure you want to disable Two-Factors Authorization
(2FA)?
</p>
<div className="flex flex-row gap-2 w-full justify-end">
<Button
variant="light"
color="danger"
onPress={() => {
setDisable2FAStepperCount(1);
}}
>
Confirm
</Button>
<Button color="primary" onPress={onClose}>
Cancel
</Button>
</div>
</div>
)}
{disable2FAStepperCount === 1 && (
<div className="flex flex-col gap-4 w-full">
<p>Let's verify that it's you.</p>
<Input
type="password"
label="Password"
value={userPassword}
onValueChange={setUserPassword}
/>
<div className="w-full flex flex-row justify-end">
<Button
onPress={() => {
verifyAccount();
}}
>
Continue
</Button>
</div>
</div>
)}
{disable2FAStepperCount === 2 && (
<div className="flex flex-col gap-4 w-full">
<p>2FA has been disabled.</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -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 (
<div className="text-center flex flex-col gap-4 w-full *:mx-auto">
<InputOtp
length={6}
value={twoFactorToken}
onValueChange={setTwoFactorToken}
size="lg"
isDisabled={twoFactorVerifying}
/>
<p className="text-sm opacity-50">
Please enter the 6 digits passcode from your authenticator app.
</p>
</div>
);
}

View File

@@ -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<UserProfile | null>(null);
@@ -185,13 +196,29 @@ export default function MemberPage() {
<Button variant="light" color="danger" onPress={logout}>
Log out
</Button>
<Button variant="light" color="primary" onPress={onOpen}>
Change password
</Button>
<div className="flex flex-row gap-2">
<Button
variant="light"
color="primary"
onPress={twoFactorAuthModalOnOpen}
>
Manage 2FA
</Button>
<Button
variant="light"
color="primary"
onPress={changePasswordModalOnOpen}
>
Change password
</Button>
</div>
</div>
</Card>
)}
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<Modal
isOpen={changePasswordModalIsOpen}
onOpenChange={changePasswordModalOnOpenChange}
>
<ModalContent>
{(onClose) => (
<>
@@ -204,6 +231,28 @@ export default function MemberPage() {
)}
</ModalContent>
</Modal>
<Modal
isOpen={twoFactorAuthModalIsOpen}
onOpenChange={twoFactorAuthModalOnOpen}
>
<ModalContent>
{(on2FAClose) => (
<>
<ModalHeader />
<ModalBody>
{userProfile && (
<Manage2FAView
onClose={on2FAClose}
userProfile={userProfile}
/>
)}
</ModalBody>
<ModalFooter />
</>
)}
</ModalContent>
</Modal>
</div>
);
}

View File

@@ -14,7 +14,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
</ItemGroup>

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,143 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<string>("Activity")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("varchar(15)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.HasKey("Id");
b.ToTable("ActivityLogs");
});
modelBuilder.Entity("AceJobAgency.Entities.User", b =>
{
b.Property<string>("Id")
.HasMaxLength(36)
.HasColumnType("varchar(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DateOfBirth")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("FailedLoginAttempts")
.HasColumnType("int");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("Gender")
.HasColumnType("int");
b.Property<int>("IsActive")
.HasColumnType("int");
b.Property<bool>("IsLockedOut")
.HasColumnType("tinyint(1)");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("LockoutEndTime")
.HasColumnType("datetime(6)");
b.Property<string>("NationalRegistrationIdentityCardNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("PreviousPassword1")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("PreviousPassword2")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("ResumeName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("Secret")
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("WhoAmI")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("varchar(2048)");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AceJobAgency.Migrations
{
/// <inheritdoc />
public partial class Added2FASecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Secret",
table: "Users",
type: "varchar(128)",
maxLength: 128,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Secret",
table: "Users");
}
}
}

View File

@@ -118,6 +118,10 @@ namespace AceJobAgency.Migrations
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("Secret")
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");