added 2FA

This commit is contained in:
biersoeckli
2024-10-29 07:38:22 +00:00
parent 9b3bad0861
commit bb9428aece
16 changed files with 452 additions and 28 deletions
+12
View File
@@ -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;
});
+25 -15
View File
@@ -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
View 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>
)
}
+24 -1
View File
@@ -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');
});
+5 -3
View File
@@ -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
View 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
View 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 >
</>;
}
+22 -4
View File
@@ -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
};
}
+6
View File
@@ -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>;
+2
View File
@@ -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
View File
@@ -0,0 +1,7 @@
import { z } from "zod";
export const totpZodModel = z.object({
totp: z.string(),
})
export type TotpModel = z.infer<typeof totpZodModel>;
+107 -5
View File
@@ -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();