mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-01 09:10:26 -06:00
added 2FA
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -44,7 +45,9 @@
|
||||
"next-auth": "^4.24.8",
|
||||
"next-themes": "^0.3.0",
|
||||
"nodemailer": "^6.9.15",
|
||||
"otpauth": "^9.3.4",
|
||||
"prisma": "^5.21.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
21
prisma/migrations/20241026155705_migration/migration.sql
Normal file
21
prisma/migrations/20241026155705_migration/migration.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"password" TEXT NOT NULL,
|
||||
"twoFaSecret" TEXT,
|
||||
"twoFaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "emailVerified", "id", "image", "name", "password", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "password", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -75,6 +75,8 @@ model User {
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String
|
||||
twoFaSecret String?
|
||||
twoFaEnabled Boolean @default(false)
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@ -14,3 +14,15 @@ export const registerUser = async (prevState: any, inputData: AuthFormInputSchem
|
||||
}
|
||||
return await userService.registerUser(validatedData.email, validatedData.password);
|
||||
});
|
||||
|
||||
export const authUser = async (inputData: AuthFormInputSchema) =>
|
||||
saveFormAction(inputData, authFormInputSchemaZod, async (validatedData) => {
|
||||
const authResult = await userService.authorize({
|
||||
username: validatedData.email,
|
||||
password: validatedData.password
|
||||
});
|
||||
if (!authResult) {
|
||||
throw new ServiceException('Username or password is incorrect');
|
||||
}
|
||||
return authResult;
|
||||
});
|
||||
@@ -11,21 +11,14 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod";
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormUtils } from "@/lib/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import SelectFormField from "@/components/custom/select-form-field";
|
||||
import BottomBarMenu from "@/components/custom/bottom-bar-menu";
|
||||
import { useState } from "react";
|
||||
import { AuthFormInputSchema, authFormInputSchemaZod } from "@/model/auth-form"
|
||||
import { registerUser } from "./actions"
|
||||
import { authUser } from "./actions"
|
||||
import { signIn } from "next-auth/react";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { redirect } from "next/navigation"
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import TwoFaAuthForm from "./two-fa-auth"
|
||||
|
||||
export default function UserLoginForm() {
|
||||
const form = useForm<AuthFormInputSchema>({
|
||||
@@ -34,16 +27,29 @@ export default function UserLoginForm() {
|
||||
|
||||
const [errorMessages, setErrorMessages] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [authInput, setAuthInput] = useState<AuthFormInputSchema | undefined>(undefined);
|
||||
|
||||
const login = async (data: AuthFormInputSchema) => {
|
||||
setLoading(true);
|
||||
setErrorMessages(undefined);
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
username: data.email,
|
||||
password: data.password,
|
||||
redirect: true,
|
||||
});
|
||||
const authStatusResponse = await authUser(data);
|
||||
if (authStatusResponse.status !== 'success') {
|
||||
throw new Error(authStatusResponse.message);
|
||||
}
|
||||
if (!authStatusResponse.data) {
|
||||
throw new Error("Unknown error occured");
|
||||
}
|
||||
const authData = authStatusResponse.data as { email: string, twoFaEnabled: boolean };
|
||||
if (!authData.twoFaEnabled) {
|
||||
await signIn("credentials", {
|
||||
username: data.email,
|
||||
password: data.password,
|
||||
redirect: true,
|
||||
});
|
||||
} else {
|
||||
setAuthInput(data); // 2fa window will be shown
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setErrorMessages((e as any).message);
|
||||
@@ -52,6 +58,10 @@ export default function UserLoginForm() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authInput) {
|
||||
return <TwoFaAuthForm authData={authInput} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-[350px] mx-auto">
|
||||
<CardHeader>
|
||||
|
||||
89
src/app/auth/two-fa-auth.tsx
Normal file
89
src/app/auth/two-fa-auth.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useState } from "react";
|
||||
import { AuthFormInputSchema, authFormInputSchemaZod, TwoFaInputSchema, twoFaInputSchemaZod } from "@/model/auth-form"
|
||||
import { signIn } from "next-auth/react";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export default function TwoFaAuthForm({
|
||||
authData
|
||||
}: {
|
||||
authData: AuthFormInputSchema
|
||||
}) {
|
||||
const form = useForm<TwoFaInputSchema>({
|
||||
resolver: zodResolver(twoFaInputSchemaZod)
|
||||
});
|
||||
|
||||
const [errorMessages, setErrorMessages] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const authWith2Fa = async (data: TwoFaInputSchema) => {
|
||||
setLoading(true);
|
||||
setErrorMessages(undefined);
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
username: authData.email,
|
||||
password: authData.password,
|
||||
totpToken: data.twoFactorCode,
|
||||
redirect: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setErrorMessages((e as any).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-[350px] mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>2FA Code</CardTitle>
|
||||
<CardDescription>Enter your 2FA code to complete the login process.</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
return form.handleSubmit(async (data) => {
|
||||
await authWith2Fa(data);
|
||||
})();
|
||||
}} className="space-y-8">
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="twoFactorCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>2FA Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-red-500">{errorMessages}</p>
|
||||
<Button type="submit" className="w-full" disabled={loading}>{loading ? <LoadingSpinner></LoadingSpinner> : 'Login'}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { ServiceException } from "@/model/service.exception.model";
|
||||
import { ProfilePasswordChangeModel, profilePasswordChangeZodModel } from "@/model/update-password.model";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { TotpModel, totpZodModel } from "@/model/update-password.model copy";
|
||||
import { SuccessActionResult } from "@/model/server-action-error-return.model";
|
||||
|
||||
export const changePassword = async (prevState: any, inputData: ProfilePasswordChangeModel) =>
|
||||
saveFormAction(inputData, profilePasswordChangeZodModel, async (validatedData) => {
|
||||
@@ -16,3 +18,24 @@ export const changePassword = async (prevState: any, inputData: ProfilePasswordC
|
||||
const session = await getAuthUserSession();
|
||||
await userService.changePassword(session.email, validatedData.oldPassword, validatedData.newPassword);
|
||||
});
|
||||
|
||||
export const createNewTotpToken = async () =>
|
||||
simpleAction(async () => {
|
||||
const session = await getAuthUserSession();
|
||||
const base64QrCode = await userService.createNewTotpToken(session.email);
|
||||
return base64QrCode;
|
||||
});
|
||||
|
||||
export const verifyTotpToken = async (prevState: any, inputData: TotpModel) =>
|
||||
saveFormAction(inputData, totpZodModel, async (validatedData) => {
|
||||
const session = await getAuthUserSession();
|
||||
await userService.verifyTotpTokenAfterCreation(session.email, validatedData.totp);
|
||||
});
|
||||
|
||||
export const deactivate2fa = async () =>
|
||||
simpleAction(async () => {
|
||||
const session = await getAuthUserSession();
|
||||
console.log(session)
|
||||
await userService.deactivate2fa(session.email);
|
||||
return new SuccessActionResult(undefined, '2FA settings deactivated successfully');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import projectService from "@/server/services/project.service";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -10,11 +9,13 @@ import {
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import ProfilePasswordChange from "./profile-password-change";
|
||||
import ToTpSettings from "./totp-settings";
|
||||
import userService from "@/server/services/user.service";
|
||||
|
||||
export default async function ProjectPage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
const data = await projectService.getAllProjects();
|
||||
const session = await getAuthUserSession();
|
||||
const data = await userService.getUserByEmail(session.email);
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<Breadcrumb>
|
||||
@@ -29,6 +30,7 @@ export default async function ProjectPage() {
|
||||
subtitle={`View or edit your Profile information and configure your authentication.`}>
|
||||
</PageTitle>
|
||||
<ProfilePasswordChange />
|
||||
<ToTpSettings totpEnabled={data.twoFaEnabled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
101
src/app/profile/totp-create-dialog.tsx
Normal file
101
src/app/profile/totp-create-dialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { FormUtils } from "@/lib/form.utilts";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFormState } from "react-dom";
|
||||
import { ServerActionResult } from "@/model/server-action-error-return.model";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createNewTotpToken, verifyTotpToken } from "./actions";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import React from "react";
|
||||
import { TotpModel, totpZodModel } from "@/model/update-password.model copy";
|
||||
import { Toast } from "@/lib/toast.utils";
|
||||
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
|
||||
|
||||
export default function TotpCreateDialog({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [totpQrCode, setTotpQrCode] = React.useState<string | null>(null);
|
||||
|
||||
const form = useForm<TotpModel>({
|
||||
resolver: zodResolver(totpZodModel)
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: TotpModel) =>
|
||||
verifyTotpToken(state, payload), FormUtils.getInitialFormState<typeof totpZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
toast.success('2FA settings updated successfully');
|
||||
form.setValue('totp', '');
|
||||
form.clearErrors();
|
||||
setIsOpen(false);
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof totpZodModel>(state, form)
|
||||
}, [state]);
|
||||
|
||||
const createTotpToken = async () => {
|
||||
setIsOpen(true);
|
||||
const response = await Toast.fromAction(() => createNewTotpToken());
|
||||
if (response.status === 'success') {
|
||||
const qrCode = response.data;
|
||||
setTotpQrCode(qrCode);
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<div onClick={() => createTotpToken()}>
|
||||
{children}
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={(isO) => setIsOpen(isO)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable 2FA</DialogTitle>
|
||||
<DialogDescription>
|
||||
To enable the Two-Facor-Authenticatoon (2FA) scan the QR code with your preferred authenticator app and enter the token below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{!totpQrCode && <div className="rounded-lg bg-slate-50 py-24"><FullLoadingSpinner /></div>}
|
||||
{totpQrCode && <><img className="mx-auto my-0" src={totpQrCode} /></>}
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>2FA Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<SubmitButton>Verify 2FA Token</SubmitButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form >
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</>;
|
||||
}
|
||||
26
src/app/profile/totp-settings.tsx
Normal file
26
src/app/profile/totp-settings.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { deactivate2fa } from "./actions";
|
||||
import { Toast } from "@/lib/toast.utils";
|
||||
import TotpCreateDialog from "./totp-create-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function ToTpSettings({ totpEnabled }: { totpEnabled: boolean }) {
|
||||
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2FA Settings</CardTitle>
|
||||
<CardDescription>Two-factor authentication (2FA) adds an extra layer of security to your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="gap-4">
|
||||
<TotpCreateDialog >
|
||||
<Button variant={totpEnabled ? 'outline' : 'default'}>{totpEnabled ? 'Replace current 2FA Config' : 'Enable 2FA'}</Button>
|
||||
</TotpCreateDialog>
|
||||
{totpEnabled && <Button onClick={() => Toast.fromAction(() => deactivate2fa())} variant="destructive">Deactivate 2FA</Button>}
|
||||
</CardFooter>
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -28,11 +28,29 @@ export const authOptions: NextAuthOptions = {
|
||||
// e.g. domain, username, password, 2FA token, etc.
|
||||
// You can pass any HTML attribute to the <input> tag through the object.
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text", placeholder: "jsmith" },
|
||||
password: { label: "Password", type: "password" }
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
totpToken: { label: "TOTP Token", type: "text" },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
return await userService.authorize(credentials);
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
const authUserInfo = await userService.authorize(credentials);
|
||||
if (!authUserInfo) {
|
||||
return null;
|
||||
}
|
||||
const user = await userService.getUserByEmail(authUserInfo.email);
|
||||
if (user.twoFaEnabled) {
|
||||
if (!credentials.totpToken) {
|
||||
return null;
|
||||
}
|
||||
const tokenValid = await userService.verifyTotpToken(authUserInfo.email, credentials.totpToken);
|
||||
if (!tokenValid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return mapUser(user);
|
||||
}
|
||||
})
|
||||
],
|
||||
@@ -61,6 +79,6 @@ export const authOptions: NextAuthOptions = {
|
||||
function mapUser(user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.email
|
||||
email: user.email
|
||||
};
|
||||
}
|
||||
@@ -7,3 +7,9 @@ export const authFormInputSchemaZod = z.object({
|
||||
|
||||
export type AuthFormInputSchema = z.infer<typeof authFormInputSchemaZod>;
|
||||
|
||||
export const twoFaInputSchemaZod = z.object({
|
||||
twoFactorCode: z.string().length(6)
|
||||
});
|
||||
|
||||
export type TwoFaInputSchema = z.infer<typeof twoFaInputSchemaZod>;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export const UserModel = z.object({
|
||||
email: z.string(),
|
||||
emailVerified: z.date().nullish(),
|
||||
password: z.string(),
|
||||
twoFaSecret: z.string().nullish(),
|
||||
twoFaEnabled: z.boolean(),
|
||||
image: z.string().nullish(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
7
src/model/update-password.model copy.ts
Normal file
7
src/model/update-password.model copy.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const totpZodModel = z.object({
|
||||
totp: z.string(),
|
||||
})
|
||||
|
||||
export type TotpModel = z.infer<typeof totpZodModel>;
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
|
||||
import { Prisma, User } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
import dataAccess from "../adapter/db.client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
import bcrypt from "bcrypt";
|
||||
import { ServiceException } from "@/model/service.exception.model";
|
||||
import QRCode from "qrcode";
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
@@ -41,8 +41,8 @@ export class UserService {
|
||||
|
||||
async maptoDtoUser(user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email
|
||||
email: user.email,
|
||||
twoFaEnabled: user.twoFaEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +90,108 @@ export class UserService {
|
||||
tags: [Tags.users()]
|
||||
})();
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string) {
|
||||
return await dataAccess.client.user.findFirstOrThrow({
|
||||
where: {
|
||||
email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createNewTotpToken(userMail: string) {
|
||||
try {
|
||||
await this.getUserByEmail(userMail);
|
||||
|
||||
let totpSecret = new OTPAuth.Secret({ size: 20 });
|
||||
|
||||
let totp = new OTPAuth.TOTP({
|
||||
// Provider or service the account is associated with.
|
||||
issuer: "QuickStack",
|
||||
// Account identifier.
|
||||
label: userMail,
|
||||
// Algorithm used for the HMAC function.
|
||||
algorithm: "SHA1",
|
||||
// Length of the generated tokens.
|
||||
digits: 6,
|
||||
// Interval of time for which a token is valid, in seconds.
|
||||
period: 30,
|
||||
// Arbitrary key encoded in base32 or OTPAuth.Secret instance
|
||||
// (if omitted, a cryptographically secure random secret is generated).
|
||||
secret: totpSecret
|
||||
});
|
||||
|
||||
let authenticatorUrl = totp.toString();
|
||||
const qrCodeForTotp = await QRCode.toDataURL(authenticatorUrl);
|
||||
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: userMail
|
||||
},
|
||||
data: {
|
||||
twoFaSecret: totp.secret.base32,
|
||||
twoFaEnabled: false
|
||||
}
|
||||
});
|
||||
return qrCodeForTotp;
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async verifyTotpTokenAfterCreation(userMail: string, token: string) {
|
||||
try {
|
||||
const isVerified = await this.verifyTotpToken(userMail, token);
|
||||
if (!isVerified) {
|
||||
throw new ServiceException("Token is invalid");
|
||||
}
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: userMail
|
||||
},
|
||||
data: {
|
||||
twoFaEnabled: true
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async verifyTotpToken(userMail: string, token: string) {
|
||||
const user = await this.getUserByEmail(userMail);
|
||||
if (!user.twoFaSecret) {
|
||||
throw new ServiceException("2FA is not enabled for this user");
|
||||
}
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "QuickStack",
|
||||
label: user.email,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: user.twoFaSecret,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token });
|
||||
return delta === 0; // 0 means the token is valid and was generated in the current time window, -1 and 1 mean the token is valid for the previous or next time window.
|
||||
}
|
||||
|
||||
async deactivate2fa(userMail: string) {
|
||||
try {
|
||||
await this.getUserByEmail(userMail);
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: userMail
|
||||
},
|
||||
data: {
|
||||
twoFaSecret: null,
|
||||
twoFaEnabled: false
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
Reference in New Issue
Block a user