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

BIN
bun.lockb

Binary file not shown.

View File

@@ -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",

View 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;

View File

@@ -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[]

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

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>

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>
)
}

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');
});

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>
)
}

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

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

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

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

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(),

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

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();