2fa
This commit is contained in:
@@ -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 { IconMail, IconLock } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import http, { login } from "../http";
|
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 }) {
|
export default function LoginView({ onSignup }: { onSignup: () => void }) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
|
||||||
const validateFields = () => {
|
const validateFields = () => {
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
@@ -16,9 +40,22 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const checkFor2FA = async () => {
|
||||||
if (!validateFields()) return;
|
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 = {
|
const loginRequest = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -79,7 +116,7 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<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
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-row gap-2 w-full justify-center *:my-auto">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
215
AceJobAgency.client/src/components/Manage2FAView.tsx
Normal file
215
AceJobAgency.client/src/components/Manage2FAView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,11 +19,22 @@ import remarkGfm from "remark-gfm";
|
|||||||
import { IconDownload, IconEdit, IconUpload } from "@tabler/icons-react";
|
import { IconDownload, IconEdit, IconUpload } from "@tabler/icons-react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ChangePasswordView from "../components/ChangePasswordView";
|
import ChangePasswordView from "../components/ChangePasswordView";
|
||||||
|
import Manage2FAView from "../components/Manage2FAView";
|
||||||
|
|
||||||
export default function MemberPage() {
|
export default function MemberPage() {
|
||||||
const accessToken = getAccessToken();
|
const accessToken = getAccessToken();
|
||||||
const navigate = useNavigate();
|
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);
|
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||||
|
|
||||||
@@ -185,13 +196,29 @@ export default function MemberPage() {
|
|||||||
<Button variant="light" color="danger" onPress={logout}>
|
<Button variant="light" color="danger" onPress={logout}>
|
||||||
Log out
|
Log out
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" color="primary" onPress={onOpen}>
|
<div className="flex flex-row gap-2">
|
||||||
Change password
|
<Button
|
||||||
</Button>
|
variant="light"
|
||||||
|
color="primary"
|
||||||
|
onPress={twoFactorAuthModalOnOpen}
|
||||||
|
>
|
||||||
|
Manage 2FA
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="primary"
|
||||||
|
onPress={changePasswordModalOnOpen}
|
||||||
|
>
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
<Modal
|
||||||
|
isOpen={changePasswordModalIsOpen}
|
||||||
|
onOpenChange={changePasswordModalOnOpenChange}
|
||||||
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
@@ -204,6 +231,28 @@ export default function MemberPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={twoFactorAuthModalIsOpen}
|
||||||
|
onOpenChange={twoFactorAuthModalOnOpen}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(on2FAClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader />
|
||||||
|
<ModalBody>
|
||||||
|
{userProfile && (
|
||||||
|
<Manage2FAView
|
||||||
|
onClose={on2FAClose}
|
||||||
|
userProfile={userProfile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
<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="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ using AceJobAgency.Entities;
|
|||||||
using AceJobAgency.Utilities;
|
using AceJobAgency.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using OtpNet;
|
||||||
|
using QRCoder;
|
||||||
|
|
||||||
namespace AceJobAgency.Controllers
|
namespace AceJobAgency.Controllers
|
||||||
{
|
{
|
||||||
@@ -102,6 +105,11 @@ namespace AceJobAgency.Controllers
|
|||||||
user.LockoutEndTime = null;
|
user.LockoutEndTime = null;
|
||||||
_context.SaveChanges();
|
_context.SaveChanges();
|
||||||
|
|
||||||
|
if (request.Verify)
|
||||||
|
{
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
var token = GenerateJwtToken(user);
|
var token = GenerateJwtToken(user);
|
||||||
new ActivityLogController(_context).LogUserActivity(user.Id, "Login successful", ipAddress);
|
new ActivityLogController(_context).LogUserActivity(user.Id, "Login successful", ipAddress);
|
||||||
return Ok(new { token });
|
return Ok(new { token });
|
||||||
@@ -337,10 +345,113 @@ namespace AceJobAgency.Controllers
|
|||||||
new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume successful", ipAddress);
|
new ActivityLogController(_context).LogUserActivity(user.Id, "Download resume successful", ipAddress);
|
||||||
return new FileStreamResult(fileStream, mimeType) { FileDownloadName = user.ResumeName };
|
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 class LoginRequest
|
||||||
{
|
{
|
||||||
|
public bool Verify { get; set; } = false;
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
public string Password { 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 CurrentPassword { get; set; } = string.Empty;
|
||||||
public string NewPassword { 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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -65,5 +65,8 @@ namespace AceJobAgency.Entities
|
|||||||
[DataType(DataType.Password)]
|
[DataType(DataType.Password)]
|
||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string PreviousPassword2 { get; set; } = string.Empty;
|
public string PreviousPassword2 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Secret { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
143
AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs
generated
Normal file
143
AceJobAgency/Migrations/20250211022931_Added2FASecret.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
AceJobAgency/Migrations/20250211022931_Added2FASecret.cs
Normal file
30
AceJobAgency/Migrations/20250211022931_Added2FASecret.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,10 @@ namespace AceJobAgency.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("varchar(128)");
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Secret")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user