├── .eslintrc.json ├── .vscode └── settings.json ├── public ├── favicon.ico └── vercel.svg ├── media ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── next.config.js ├── utils ├── nodemailer.ts ├── hash.ts ├── ErrorCode.ts ├── db.ts └── crypto.ts ├── pages ├── _app.tsx ├── api │ ├── user.ts │ ├── auth │ │ ├── signup.ts │ │ ├── two-factor │ │ │ └── totp │ │ │ │ ├── enable.ts │ │ │ │ ├── setup.ts │ │ │ │ └── disable.ts │ │ └── [...nextauth].ts │ └── reset-password.ts ├── index.tsx ├── profile.tsx └── reset-password │ ├── index.tsx │ └── [token].tsx ├── styles ├── globals.css └── Home.module.css ├── .gitignore ├── models ├── Token.ts └── User.ts ├── tsconfig.json ├── package.json ├── LICENSE ├── components ├── TwoFactAuth.tsx ├── form.tsx ├── signup.tsx ├── signin.tsx └── TwoFactSettings.tsx └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bharathvaj-ganesan/next-auth-2fa-example/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /media/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bharathvaj-ganesan/next-auth-2fa-example/HEAD/media/screenshot1.png -------------------------------------------------------------------------------- /media/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bharathvaj-ganesan/next-auth-2fa-example/HEAD/media/screenshot2.png -------------------------------------------------------------------------------- /media/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bharathvaj-ganesan/next-auth-2fa-example/HEAD/media/screenshot3.png -------------------------------------------------------------------------------- /media/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bharathvaj-ganesan/next-auth-2fa-example/HEAD/media/screenshot4.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /utils/nodemailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | 3 | const email = process.env.EMAIL; 4 | const pass = process.env.EMAIL_PASS; 5 | export const transporter = nodemailer.createTransport({ 6 | service: 'gmail', 7 | auth: { 8 | user: email, 9 | pass, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /utils/hash.ts: -------------------------------------------------------------------------------- 1 | import { compare, hash } from 'bcryptjs'; 2 | 3 | export async function hashPassword(password: string) { 4 | const hashedPassword = await hash(password, 12); 5 | return hashedPassword; 6 | } 7 | 8 | export async function isPasswordValid(password: string, hashedPassword: string) { 9 | const isValid = await compare(password, hashedPassword); 10 | return isValid; 11 | } 12 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { ChakraProvider } from '@chakra-ui/react'; 5 | function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default MyApp; 16 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | @media (prefers-color-scheme: dark) { 19 | html { 20 | color-scheme: dark; 21 | } 22 | body { 23 | color: white; 24 | background: black; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /models/Token.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | interface IToken { 4 | userId: mongoose.Schema.Types.ObjectId; 5 | token: string; 6 | createdAt: Date; 7 | } 8 | const tokenSchema = new mongoose.Schema({ 9 | userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'user' }, 10 | token: { type: String, required: true }, 11 | createdAt: { type: Date, default: Date.now, expires: 300 }, // Exprire in 5 mins 12 | }); 13 | 14 | const Token = 15 | mongoose.models.Token || mongoose.model('Token', tokenSchema); 16 | export default Token; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /utils/ErrorCode.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | IncorrectUsernamePassword = 'incorrect-username-password', 3 | UserNotFound = 'user-not-found', 4 | IncorrectPassword = 'incorrect-password', 5 | UserMissingPassword = 'missing-password', 6 | TwoFactorDisabled = 'two-factor-disabled', 7 | TwoFactorAlreadyEnabled = 'two-factor-already-enabled', 8 | TwoFactorSetupRequired = 'two-factor-setup-required', 9 | SecondFactorRequired = 'second-factor-required', 10 | IncorrectTwoFactorCode = 'incorrect-two-factor-code', 11 | InternalServerError = 'internal-server-error', 12 | NewPasswordMatchesOld = 'new-password-matches-old', 13 | ThirdPartyIdentityProviderEnabled = 'third-party-identity-provider-enabled', 14 | } 15 | -------------------------------------------------------------------------------- /utils/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const MONGODB_URI = process.env.MONGODB_URI!; 4 | 5 | //@ts-ignore 6 | let cached = global.mongoose; 7 | 8 | if (!cached) { 9 | //@ts-ignore 10 | cached = global.mongoose = { conn: null, promise: null }; 11 | } 12 | 13 | async function connect() { 14 | if (cached.conn) { 15 | return cached.conn; 16 | } 17 | 18 | if (!cached.promise) { 19 | const opts = { 20 | bufferCommands: false, 21 | }; 22 | 23 | cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => { 24 | return mongoose; 25 | }); 26 | } 27 | 28 | cached.conn = await cached.promise; 29 | return cached.conn; 30 | } 31 | 32 | async function disconnect() { 33 | await mongoose.disconnect(); 34 | } 35 | 36 | const db = { connect, disconnect }; 37 | export default db; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-2fa-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/react": "^2.4.9", 13 | "@types/bcryptjs": "^2.4.2", 14 | "bcryptjs": "^2.4.3", 15 | "mongodb": "^4.13.0", 16 | "mongoose": "^6.9.0", 17 | "next": "13.1.5", 18 | "next-auth": "^4.18.10", 19 | "nodemailer": "^6.9.0", 20 | "otplib": "^12.0.1", 21 | "qrcode": "^1.5.1", 22 | "react": "18.2.0", 23 | "react-digit-input": "^2.1.0", 24 | "react-dom": "18.2.0" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "18.11.18", 28 | "@types/nodemailer": "^6.4.7", 29 | "@types/qrcode": "^1.5.0", 30 | "@types/react": "18.0.27", 31 | "@types/react-dom": "18.0.10", 32 | "eslint": "8.32.0", 33 | "eslint-config-next": "13.1.5", 34 | "typescript": "4.9.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /models/User.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document } from 'mongoose'; 2 | 3 | export interface IUser extends Document { 4 | name: string; 5 | email: string; 6 | password: string; 7 | twoFactorEnabled: boolean; 8 | twoFactorSecret?: string; 9 | toJSON(): any; 10 | } 11 | 12 | const userSchema = new mongoose.Schema( 13 | { 14 | name: { type: String, required: true }, 15 | email: { type: String, required: true }, 16 | password: { type: String, required: true }, 17 | twoFactorEnabled: { type: Boolean, required: false, default: false }, 18 | twoFactorSecret: { type: String, required: false }, 19 | }, 20 | { 21 | toObject: { 22 | transform: function (doc: any, ret: any) { 23 | delete ret._id; 24 | }, 25 | }, 26 | toJSON: { 27 | transform: function (doc: any, ret: any) { 28 | delete ret._id; 29 | }, 30 | }, 31 | } 32 | ); 33 | 34 | const User = mongoose.models.User || mongoose.model('User', userSchema); 35 | 36 | export default User; 37 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bharathvaj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pages/api/user.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getSession } from 'next-auth/react'; 3 | import { omit } from 'lodash'; 4 | import User, { IUser } from '../../models/User'; 5 | import { ErrorCode } from '../../utils/ErrorCode'; 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | if (req.method !== 'POST') { 9 | return res.status(405).json({ message: 'Method not allowed' }); 10 | } 11 | 12 | const session = await getSession({ req }); 13 | if (!session) { 14 | return res.status(401).json({ message: 'Not authenticated' }); 15 | } 16 | 17 | if (!session.user?.email) { 18 | console.error('Session is missing a user id.'); 19 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 20 | } 21 | 22 | const user = await User.findOne({ email: session.user.email }); 23 | 24 | if (!user) { 25 | console.error(`Session references user that no longer exists.`); 26 | return res.status(401).json({ message: 'Not authenticated' }); 27 | } 28 | 29 | return res.json(omit(user, 'twoFactorSecret', 'password')); 30 | } 31 | -------------------------------------------------------------------------------- /components/TwoFactAuth.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Input } from '@chakra-ui/react'; 2 | import useDigitInput from 'react-digit-input'; 3 | 4 | export default function TwoFactAuth({ value, onChange }: { value: string; onChange: (value: string) => void }) { 5 | const digits = useDigitInput({ 6 | acceptedCharacters: /^[0-9]$/, 7 | length: 6, 8 | value, 9 | onChange, 10 | }); 11 | 12 | const className = 'h-12 w-12 !text-xl text-center'; 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /pages/api/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import User from '../../../models/User'; 3 | import db from '../../../utils/db'; 4 | import { hashPassword } from '../../../utils/hash'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | if (req.method === 'POST') { 11 | const newUser = req.body; 12 | 13 | await db.connect(); 14 | 15 | // Check if user exists 16 | const userExists = await User.findOne({ email: newUser.email }); 17 | if (userExists) { 18 | res.status(422).json({ 19 | success: false, 20 | message: 'A user with the same email already exists!', 21 | userExists: true, 22 | }); 23 | return; 24 | } 25 | 26 | // Hash Password 27 | newUser.password = await hashPassword(newUser.password); 28 | 29 | // Store new user 30 | const storeUser = new User(newUser); 31 | await storeUser.save(); 32 | 33 | res 34 | .status(201) 35 | .json({ success: true, message: 'User signed up successfuly' }); 36 | } else { 37 | res.status(400).json({ success: false, message: 'Invalid method' }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | import type { GetServerSidePropsContext, NextPage } from 'next'; 3 | import { getSession } from 'next-auth/react'; 4 | import Head from 'next/head'; 5 | import Form from '../components/form'; 6 | 7 | const Home: NextPage = () => { 8 | return ( 9 | 16 | 17 | Sign In | Create an Account 18 | 19 | 20 | 21 |
22 | 23 | ); 24 | }; 25 | 26 | //@ts-ignore 27 | export const getServerSideProps: GetServerSideProps = async ( 28 | context: GetServerSidePropsContext 29 | ) => { 30 | const session = await getSession({ req: context.req }); 31 | 32 | if (session) { 33 | return { 34 | redirect: { 35 | destination: '/profile', 36 | permananet: false, 37 | }, 38 | }; 39 | } 40 | 41 | return { 42 | props: { session }, 43 | }; 44 | }; 45 | 46 | export default Home; 47 | -------------------------------------------------------------------------------- /components/form.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex } from '@chakra-ui/react'; 2 | import { useState } from 'react'; 3 | import SignIn from './signin'; 4 | import SignUp from './signup'; 5 | 6 | export default function Form() { 7 | const [isSignInMode, setSignInMode] = useState(false); 8 | return ( 9 | 10 | 20 | 21 | 22 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | const ALGORITHM = 'aes256'; 4 | const INPUT_ENCODING = 'utf8'; 5 | const OUTPUT_ENCODING = 'hex'; 6 | const IV_LENGTH = 16; // AES blocksize 7 | 8 | /** 9 | * 10 | * @param text Value to be encrypted 11 | * @param key Key used to encrypt value must be 32 bytes for AES256 encryption algorithm 12 | * 13 | * @returns Encrypted value using key 14 | */ 15 | export const symmetricEncrypt = function (text: string, key: string) { 16 | const _key = Buffer.from(key, 'latin1'); 17 | const iv = crypto.randomBytes(IV_LENGTH); 18 | 19 | const cipher = crypto.createCipheriv(ALGORITHM, _key, iv); 20 | let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING); 21 | ciphered += cipher.final(OUTPUT_ENCODING); 22 | const ciphertext = iv.toString(OUTPUT_ENCODING) + ':' + ciphered; 23 | 24 | return ciphertext; 25 | }; 26 | 27 | /** 28 | * 29 | * @param text Value to decrypt 30 | * @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm 31 | */ 32 | export const symmetricDecrypt = function (text: string, key: string) { 33 | const _key = Buffer.from(key, 'latin1'); 34 | 35 | const components = text.split(':'); 36 | const iv_from_ciphertext = Buffer.from(components.shift() || '', OUTPUT_ENCODING); 37 | const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext); 38 | let deciphered = decipher.update(components.join(':'), OUTPUT_ENCODING, INPUT_ENCODING); 39 | deciphered += decipher.final(INPUT_ENCODING); 40 | 41 | return deciphered; 42 | }; 43 | -------------------------------------------------------------------------------- /pages/profile.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Text } from '@chakra-ui/react'; 2 | import { GetServerSideProps, GetServerSidePropsContext } from 'next'; 3 | import { getSession, signOut, useSession } from 'next-auth/react'; 4 | import TwoFactSettings from '../components/TwoFactSettings'; 5 | import User, { IUser } from '../models/User'; 6 | 7 | export default function Profile({ user }: { user: IUser }) { 8 | return ( 9 | 10 | 11 | Welcome {user?.name} to our website 12 | 13 | 14 | 31 | 32 | ); 33 | } 34 | 35 | //@ts-ignore 36 | export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => { 37 | const session = await getSession({ req: context.req }); 38 | const user = await User.findOne({ email: session?.user?.email }).select({ password: 0, twoFactorSecret: 0 }); 39 | 40 | if (!session) { 41 | return { 42 | redirect: { 43 | destination: '/', 44 | permananet: false, 45 | }, 46 | }; 47 | } 48 | 49 | return { 50 | props: { session, user: user?.toJSON() }, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![alt text](https://github.githubassets.com/images/icons/emoji/unicode/1f510.png) next-auth-2fa-example 2 | 3 | Example showing a custom sign-in page using NextAuth.js with Two Factor Authentication using [TOTP algorithm](https://en.wikipedia.org/wiki/Time-based_one-time_password). 4 | 5 | ## Features 6 | 7 | - Users can manage 2FA 8 | - Enforce 2FA during login 9 | - First class integration with NextAuth.js 10 | 11 | ![Screenshot 1](./media/screenshot1.png) 12 | ![Screenshot 2](./media/screenshot2.png) 13 | ![Screenshot 3](./media/screenshot3.png) 14 | ![Screenshot 4](./media/screenshot4.png) 15 | 16 | ## 🚀 Getting Started 17 | 18 | First, run the development server: 19 | 20 | 1. Install dependencies 21 | 22 | ```bash 23 | $ npm Install 24 | ``` 25 | 26 | 2. Start dev server 27 | 28 | ```bash 29 | $ npm run dev 30 | # or 31 | $ yarn dev 32 | ``` 33 | 34 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 35 | 36 | ## 🛡 NextAuth.js 37 | 38 | You will find more examples under https://next-auth.js.org/. 39 | 40 | ## Learn More 41 | 42 | To learn more about Next.js, take a look at the following resources: 43 | 44 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 45 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 46 | 47 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 48 | 49 | ## Deploy on Vercel 50 | 51 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 52 | 53 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 54 | -------------------------------------------------------------------------------- /pages/api/auth/two-factor/totp/enable.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getSession } from 'next-auth/react'; 3 | import { authenticator } from 'otplib'; 4 | import User, { IUser } from '../../../../../models/User'; 5 | import { symmetricDecrypt } from '../../../../../utils/crypto'; 6 | import { ErrorCode } from '../../../../../utils/ErrorCode'; 7 | 8 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 9 | if (req.method !== 'POST') { 10 | return res.status(405).json({ message: 'Method not allowed' }); 11 | } 12 | 13 | const session = await getSession({ req }); 14 | if (!session) { 15 | return res.status(401).json({ message: 'Not authenticated' }); 16 | } 17 | 18 | if (!session.user?.email) { 19 | console.error('Session is missing a user email.'); 20 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 21 | } 22 | 23 | const user = await User.findOne({ email: session.user.email }); 24 | 25 | if (!user) { 26 | console.error(`Session references user that no longer exists.`); 27 | return res.status(401).json({ message: 'Not authenticated' }); 28 | } 29 | 30 | if (user.twoFactorEnabled) { 31 | return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled }); 32 | } 33 | 34 | if (!user.twoFactorSecret) { 35 | return res.status(400).json({ error: ErrorCode.TwoFactorSetupRequired }); 36 | } 37 | 38 | if (!process.env.ENCRYPTION_KEY) { 39 | console.error('Missing encryption key; cannot proceed with two factor setup.'); 40 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 41 | } 42 | 43 | const secret = symmetricDecrypt(user.twoFactorSecret, process.env.ENCRYPTION_KEY); 44 | if (secret.length !== 32) { 45 | console.error(`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`); 46 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 47 | } 48 | 49 | const isValidToken = authenticator.check(req.body.totpCode, secret); 50 | if (!isValidToken) { 51 | return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode }); 52 | } 53 | 54 | await User.updateOne( 55 | { email: session.user?.email }, 56 | { 57 | twoFactorEnabled: true, 58 | } 59 | ); 60 | 61 | return res.json({ message: 'Two-factor enabled' }); 62 | } 63 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | .card, 120 | .footer { 121 | border-color: #222; 122 | } 123 | .code { 124 | background: #111; 125 | } 126 | .logo img { 127 | filter: invert(1); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /pages/api/auth/two-factor/totp/setup.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { authenticator } from 'otplib'; 3 | import qrcode from 'qrcode'; 4 | import { symmetricEncrypt } from '../../../../../utils/crypto'; 5 | import { getSession, signOut, useSession } from 'next-auth/react'; 6 | import { ErrorCode } from '../../../../../utils/ErrorCode'; 7 | import User, { IUser } from '../../../../../models/User'; 8 | import { isPasswordValid } from '../../../../../utils/hash'; 9 | 10 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 11 | if (req.method !== 'POST') { 12 | return res.status(405).json({ message: 'Method not allowed' }); 13 | } 14 | 15 | const session = await getSession({ req }); 16 | if (!session) { 17 | return res.status(401).json({ message: 'Not authenticated' }); 18 | } 19 | 20 | if (!session.user?.email) { 21 | console.error('Session is missing a user email.'); 22 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 23 | } 24 | 25 | const user = await User.findOne({ email: session.user?.email }); 26 | 27 | if (!user) { 28 | console.error(`Session references user that no longer exists.`); 29 | return res.status(401).json({ message: 'Not authenticated' }); 30 | } 31 | 32 | if (!user.password) { 33 | return res.status(400).json({ error: ErrorCode.UserMissingPassword }); 34 | } 35 | 36 | if (user.twoFactorEnabled) { 37 | return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled }); 38 | } 39 | 40 | if (!process.env.ENCRYPTION_KEY) { 41 | console.error('Missing encryption key; cannot proceed with two factor setup.'); 42 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 43 | } 44 | 45 | const isCorrectPassword = await isPasswordValid(req.body.password, user.password); 46 | 47 | if (!isCorrectPassword) { 48 | return res.status(400).json({ error: ErrorCode.IncorrectPassword }); 49 | } 50 | 51 | // This generates a secret 32 characters in length. Do not modify the number of 52 | // bytes without updating the sanity checks in the enable and login endpoints. 53 | const secret = authenticator.generateSecret(20); 54 | 55 | await User.updateOne( 56 | { email: session.user?.email }, 57 | { 58 | twoFactorEnabled: false, 59 | twoFactorSecret: symmetricEncrypt(secret, process.env.ENCRYPTION_KEY), 60 | } 61 | ); 62 | 63 | const name = user.email; 64 | const keyUri = authenticator.keyuri(name, 'MyApp', secret); 65 | const dataUri = await qrcode.toDataURL(keyUri); 66 | 67 | return res.json({ secret, keyUri, dataUri }); 68 | } 69 | -------------------------------------------------------------------------------- /pages/reset-password/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Flex, 4 | FormControl, 5 | FormErrorMessage, 6 | Input, 7 | Text, 8 | useToast, 9 | VStack, 10 | } from '@chakra-ui/react'; 11 | import Link from 'next/link'; 12 | import { useRouter } from 'next/router'; 13 | import React, { useState } from 'react'; 14 | 15 | export default function ResetPassword() { 16 | const [email, setEmail] = useState(''); 17 | const [touched, setTouched] = useState(false); 18 | const [isLoading, setIsLoading] = useState(false); 19 | const toast = useToast(); 20 | 21 | const handleSubmit = async (e: React.SyntheticEvent) => { 22 | e.preventDefault(); 23 | 24 | setIsLoading(true); 25 | 26 | await fetch('/api/reset-password', { 27 | method: 'POST', 28 | body: JSON.stringify(email), 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | Accept: 'application/json', 32 | }, 33 | }) 34 | .then((res) => { 35 | setIsLoading(false); 36 | toast({ 37 | title: 'Message Sent', 38 | status: 'success', 39 | duration: 2000, 40 | position: 'top', 41 | }); 42 | console.log(res); 43 | }) 44 | .catch((error) => console.log('Error: ', error)); 45 | }; 46 | 47 | return ( 48 | 55 | 56 | 57 | Enter the email address associated with your account, and we will send 58 | you a link to reset your password 59 | 60 | 61 | 62 | setEmail(e.target.value)} 66 | placeholder={'Enter your email address'} 67 | w={'100%'} 68 | onBlur={() => setTouched(true)} 69 | /> 70 | Email is required 71 | 72 | 81 | 82 | 83 | Back to Login 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /pages/reset-password/[token].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Flex, 4 | FormControl, 5 | FormErrorMessage, 6 | Input, 7 | Text, 8 | useToast, 9 | VStack, 10 | } from '@chakra-ui/react'; 11 | import Link from 'next/link'; 12 | import { useRouter } from 'next/router'; 13 | import React, { useState } from 'react'; 14 | 15 | export default function ResetPassword() { 16 | const [password, setPassword] = useState(''); 17 | const [touched, setTouched] = useState(false); 18 | const [isLoading, setIsLoading] = useState(false); 19 | const toast = useToast(); 20 | const router = useRouter(); 21 | 22 | const handleSubmit = async (e: React.SyntheticEvent) => { 23 | e.preventDefault(); 24 | 25 | setIsLoading(true); 26 | 27 | const newPassword = { 28 | tokenId: router.query.token, 29 | password, 30 | }; 31 | 32 | await fetch('/api/reset-password', { 33 | method: 'PUT', 34 | body: JSON.stringify(newPassword), 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | Accept: 'application/json', 38 | }, 39 | }) 40 | .then((res) => { 41 | setIsLoading(false); 42 | toast({ 43 | title: 'Password is reset', 44 | status: 'success', 45 | duration: 2000, 46 | position: 'top', 47 | }); 48 | }) 49 | .catch((error) => console.log('Error: ', error)); 50 | 51 | router.replace('/'); 52 | }; 53 | 54 | return ( 55 | 62 | 63 | Enter a new password for your account 64 |
65 | 66 | setPassword(e.target.value)} 70 | placeholder={'New Password'} 71 | minLength={8} 72 | w={'100%'} 73 | onBlur={() => setTouched(true)} 74 | /> 75 | Password is required 76 | 77 | 86 |
87 | 88 | Back to Login 89 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /pages/api/auth/two-factor/totp/disable.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getSession } from 'next-auth/react'; 3 | import { authenticator } from 'otplib'; 4 | import User, { IUser } from '../../../../../models/User'; 5 | import { symmetricDecrypt } from '../../../../../utils/crypto'; 6 | import { ErrorCode } from '../../../../../utils/ErrorCode'; 7 | import { isPasswordValid } from '../../../../../utils/hash'; 8 | 9 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 10 | if (req.method !== 'POST') { 11 | return res.status(405).json({ message: 'Method not allowed' }); 12 | } 13 | 14 | const session = await getSession({ req }); 15 | if (!session) { 16 | return res.status(401).json({ message: 'Not authenticated' }); 17 | } 18 | 19 | if (!session.user?.email) { 20 | console.error('Session is missing a user id.'); 21 | return res.status(500).json({ error: ErrorCode.InternalServerError }); 22 | } 23 | 24 | const user = await User.findOne({ email: session.user.email }); 25 | 26 | if (!user) { 27 | console.error(`Session references user that no longer exists.`); 28 | return res.status(401).json({ message: 'Not authenticated' }); 29 | } 30 | 31 | if (!user.password) { 32 | return res.status(400).json({ error: ErrorCode.UserMissingPassword }); 33 | } 34 | 35 | if (!user.twoFactorEnabled) { 36 | return res.json({ message: 'Two factor disabled' }); 37 | } 38 | 39 | // if user has 2fa 40 | if (user.twoFactorEnabled) { 41 | if (!req.body.totpCode) { 42 | return res.status(400).json({ error: ErrorCode.SecondFactorRequired }); 43 | } 44 | 45 | if (!user.twoFactorSecret) { 46 | console.error(`Two factor is enabled for user ${user.email} but they have no secret`); 47 | throw new Error(ErrorCode.InternalServerError); 48 | } 49 | 50 | if (!process.env.ENCRYPTION_KEY) { 51 | console.error(`"Missing encryption key; cannot proceed with two factor login."`); 52 | throw new Error(ErrorCode.InternalServerError); 53 | } 54 | 55 | const secret = symmetricDecrypt(user.twoFactorSecret, process.env.ENCRYPTION_KEY); 56 | if (secret.length !== 32) { 57 | console.error(`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`); 58 | throw new Error(ErrorCode.InternalServerError); 59 | } 60 | 61 | // If user has 2fa enabled, check if body.totpCode is correct 62 | const isValidToken = authenticator.check(req.body.totpCode, secret); 63 | if (!isValidToken) { 64 | return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode }); 65 | } 66 | } 67 | 68 | // If it is, disable users 2fa 69 | await User.updateOne( 70 | { email: session.user?.email }, 71 | { 72 | twoFactorEnabled: false, 73 | twoFactorSecret: null, 74 | } 75 | ); 76 | 77 | return res.json({ message: 'Two factor disabled' }); 78 | } 79 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import CredentialsProvider from 'next-auth/providers/credentials'; 3 | import { authenticator } from 'otplib'; 4 | import User, { IUser } from '../../../models/User'; 5 | import { symmetricDecrypt } from '../../../utils/crypto'; 6 | import db from '../../../utils/db'; 7 | import { ErrorCode } from '../../../utils/ErrorCode'; 8 | import { isPasswordValid } from '../../../utils/hash'; 9 | 10 | export default NextAuth({ 11 | pages: { 12 | signIn: '/', 13 | }, 14 | providers: [ 15 | CredentialsProvider({ 16 | id: 'credentials', 17 | name: 'Credentials', 18 | credentials: { 19 | email: { label: 'Email Address', type: 'email', placeholder: 'john.doe@example.com' }, 20 | password: { label: 'Password', type: 'password', placeholder: 'Your super secure password' }, 21 | totpCode: { label: 'Two-factor Code', type: 'input', placeholder: 'Code from authenticator app' }, 22 | }, 23 | //@ts-ignore 24 | async authorize(credentials: any) { 25 | await db.connect(); 26 | 27 | const user = await User.findOne({ email: credentials.email }); 28 | 29 | // Check if user exists 30 | if (!user) { 31 | return null; 32 | } 33 | 34 | // Validate password 35 | const isPasswordMatch = await isPasswordValid(credentials.password, user.password); 36 | 37 | if (!isPasswordMatch) { 38 | return null; 39 | } 40 | 41 | if (user.twoFactorEnabled) { 42 | if (!credentials.totpCode) { 43 | throw new Error(ErrorCode.SecondFactorRequired); 44 | } 45 | 46 | if (!user.twoFactorSecret) { 47 | console.error(`Two factor is enabled for user ${user.email} but they have no secret`); 48 | throw new Error(ErrorCode.InternalServerError); 49 | } 50 | 51 | if (!process.env.ENCRYPTION_KEY) { 52 | console.error(`"Missing encryption key; cannot proceed with two factor login."`); 53 | throw new Error(ErrorCode.InternalServerError); 54 | } 55 | 56 | const secret = symmetricDecrypt(user.twoFactorSecret!, process.env.ENCRYPTION_KEY!); 57 | if (secret.length !== 32) { 58 | console.error(`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`); 59 | throw new Error(ErrorCode.InternalServerError); 60 | } 61 | 62 | const isValidToken = authenticator.check(credentials.totpCode, secret); 63 | if (!isValidToken) { 64 | throw new Error(ErrorCode.IncorrectTwoFactorCode); 65 | } 66 | } 67 | 68 | if (user) 69 | return { 70 | name: user.name, 71 | email: user.email, 72 | }; 73 | }, 74 | }), 75 | ], 76 | 77 | secret: process.env.ENCRYPTION_KEY, 78 | session: { 79 | strategy: 'jwt', 80 | maxAge: 30 * 24 * 60 * 60, // 30 Days 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /pages/api/reset-password.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import Token from '../../models/Token'; 4 | import User from '../../models/User'; 5 | import db from '../../utils/db'; 6 | import { hashPassword } from '../../utils/hash'; 7 | import { transporter } from '../../utils/nodemailer'; 8 | 9 | export default async function handle( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | if (req.method === 'POST') { 14 | const email = req.body; 15 | 16 | try { 17 | await db.connect(); 18 | 19 | // Check for user existence 20 | const user = await User.findOne({ email: email }); 21 | 22 | if (!user) { 23 | return res.status(422).json({ messge: "User doesn't exists!" }); 24 | } else { 25 | const token = await Token.findOne({ userId: user._id }); 26 | 27 | if (token) { 28 | await token.deleteOne(); 29 | } 30 | 31 | // Create a token id 32 | const securedTokenId = nanoid(32); 33 | 34 | // Store token in DB 35 | await new Token({ 36 | userId: user._id, 37 | token: securedTokenId, 38 | createdAt: Date.now(), 39 | }).save(); 40 | 41 | // Link send to user's email for resetting 42 | const link = `${process.env.WEB_URI}/reset-password/${securedTokenId}`; 43 | 44 | await transporter.sendMail({ 45 | from: process.env.EMAIL, 46 | to: user.email, 47 | subject: 'Reset Password', 48 | text: 'Reset Password Messsage', 49 | html: ` 50 |
51 |

Follow the following link

52 |

Please follow 53 | this link 54 | to reset your password 55 |

56 |
57 | `, 58 | }); 59 | } 60 | } catch (error: any) { 61 | return res.status(400).send({ message: error.message }); 62 | } 63 | 64 | // Success 65 | res.status(200).json({ success: true }); 66 | } else if (req.method === 'PUT') { 67 | const { tokenId, password } = req.body; 68 | 69 | // Get token from DB 70 | const token = await Token.findOne({ token: tokenId }); 71 | 72 | if (!token) { 73 | return res.status(400).json({ 74 | success: false, 75 | message: 'Invalid or expired password reset token', 76 | }); 77 | } 78 | 79 | // Return user 80 | const user = await User.findOne({ _id: token.userId }); 81 | 82 | // Hash password before resetting 83 | const hashedPassword = await hashPassword(password); 84 | 85 | await User.updateOne( 86 | { _id: user._id }, 87 | { password: hashedPassword }, 88 | { new: true } 89 | ); 90 | 91 | await transporter.sendMail({ 92 | from: process.env.EMAIL, 93 | to: user.email, 94 | subject: 'Password reset successufly', 95 | html: 'Password is successfuly reset', 96 | }); 97 | 98 | // Delete token so it won't be used twice 99 | const deleteToken = await Token.deleteOne({ _id: token._id }); 100 | 101 | if (!deleteToken) { 102 | res.status(403).end(); 103 | } 104 | 105 | res 106 | .status(200) 107 | .json({ seccuess: true, message: 'Password is reset successfuly' }); 108 | } else { 109 | res.status(400).json({ success: false, message: 'Bad request' }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /components/signup.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, FormControl, FormErrorMessage, Heading, Input, Text, VStack } from '@chakra-ui/react'; 2 | import { Dispatch, SetStateAction, useState } from 'react'; 3 | 4 | interface SignUpProps { 5 | isSignInMode: boolean; 6 | setSignInMode: Dispatch>; 7 | } 8 | export default function SignUn(props: SignUpProps) { 9 | const [name, setName] = useState(''); 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [isEmailInvalid, setEmailInvalid] = useState(false); 13 | 14 | // Validate inputs 15 | const validateInputs = () => setEmailInvalid(true); 16 | 17 | const handleSignup = async (e: React.SyntheticEvent) => { 18 | e.preventDefault(); 19 | 20 | const newUser = { 21 | name, 22 | email, 23 | password, 24 | }; 25 | 26 | const response = await fetch('/api/auth/signup', { 27 | method: 'POST', 28 | body: JSON.stringify(newUser), 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | }, 32 | }); 33 | 34 | const data = await response.json(); 35 | if (data.userExists) { 36 | validateInputs(); 37 | } else { 38 | setEmailInvalid(false); 39 | setName(''); 40 | setEmail(''); 41 | setPassword(''); 42 | props.setSignInMode(true); 43 | } 44 | }; 45 | return ( 46 | 47 | {props.isSignInMode ? ( 48 | 49 | 50 | New Here? 51 | 52 | 53 | Create an account and start your journey with us 54 | 55 | 66 | 67 | ) : ( 68 | 69 | Create Account 70 |
71 | 72 | 73 | setName(e.target.value)} /> 74 | 75 | 76 | setEmail(e.target.value)} /> 77 | A user already exist with the entered email 78 | 79 | 80 | setPassword(e.target.value)} /> 81 | 82 | 83 | 98 | 99 | 100 |
101 |
102 | )} 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /components/signin.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, FormControl, FormErrorMessage, Heading, Input, Text, VStack, useToast } from '@chakra-ui/react'; 2 | import React, { Dispatch, SetStateAction, useState } from 'react'; 3 | import { signIn, useSession } from 'next-auth/react'; 4 | import { useRouter } from 'next/router'; 5 | import Link from 'next/link'; 6 | import { ErrorCode } from '../utils/ErrorCode'; 7 | import TwoFactAuth from './TwoFactAuth'; 8 | 9 | interface SignInProps { 10 | isSignInMode: boolean; 11 | setSignInMode: Dispatch>; 12 | } 13 | export default function SignIn(props: SignInProps) { 14 | const [email, setEmail] = useState(''); 15 | const [password, setPassword] = useState(''); 16 | const [totpCode, setTotpCode] = useState(''); 17 | const [showOTP, setShowOTP] = useState(false); 18 | const toast = useToast(); 19 | 20 | const { data: session } = useSession(); 21 | console.log('Session: ', session); 22 | 23 | const router = useRouter(); 24 | 25 | const handleSignIn = async (e: React.SyntheticEvent) => { 26 | e.preventDefault(); 27 | 28 | console.log('Email: ', email); 29 | await signIn('credentials', { 30 | redirect: false, 31 | email, 32 | password, 33 | totpCode, 34 | }) 35 | .then((response) => { 36 | if (response?.ok) { 37 | router.replace('/profile'); 38 | return; 39 | } 40 | 41 | switch (response?.error) { 42 | case ErrorCode.IncorrectPassword: 43 | toast({ 44 | title: 'Invalid credentials', 45 | status: 'error', 46 | }); 47 | return; 48 | case ErrorCode.SecondFactorRequired: 49 | setShowOTP(true); 50 | return; 51 | } 52 | }) 53 | .catch(() => { 54 | toast({ 55 | title: 'Sorry something went wrong', 56 | status: 'error', 57 | }); 58 | }); 59 | }; 60 | return ( 61 | 62 | {props.isSignInMode ? ( 63 | 64 | 65 | Sign In 66 | 67 |
68 | 69 | 70 | setEmail(e.target.value)} /> 71 | {email === '' ? 'Email is requi#319795' : 'Invalid email'} 72 | 73 | 74 | setPassword(e.target.value)} /> 75 | Password cannot be less than 8 characters 76 | 77 | {showOTP && setTotpCode(val)} />} 78 | 79 | 80 | 83 | 84 | 85 | 100 | 101 |
102 |
103 | ) : ( 104 | 105 | 106 | Welcome Back! 107 | 108 | 109 | To continue your journey with us, please login 110 | 111 | 122 | 123 | )} 124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /components/TwoFactSettings.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalOverlay, Flex, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, FormControl, FormLabel, Switch, useDisclosure, Text, Input, useToast } from '@chakra-ui/react'; 2 | import { useState } from 'react'; 3 | import { ErrorCode } from '../utils/ErrorCode'; 4 | import TwoFactAuth from './TwoFactAuth'; 5 | import { IUser } from '../models/User'; 6 | 7 | enum SetupStep { 8 | ConfirmPassword, 9 | DisplayQrCode, 10 | EnterTotpCode, 11 | } 12 | 13 | const WithStep = ({ step, current, children }: { step: SetupStep; current: SetupStep; children: JSX.Element }) => { 14 | return step === current ? children : null; 15 | }; 16 | 17 | const TwoFactSetupModal = ({ isOpen, onClose, onEnable }: { isOpen: boolean; onClose: () => void; onEnable: () => void }) => { 18 | const [dataUri, setDataUri] = useState(''); 19 | const [password, setPassword] = useState(''); 20 | const [totpCode, setTotpCode] = useState(''); 21 | const toast = useToast(); 22 | const [isSubmitting, setIsSubmitting] = useState(false); 23 | const [step, setStep] = useState(SetupStep.ConfirmPassword); 24 | 25 | async function handleSetup() { 26 | if (isSubmitting) { 27 | return; 28 | } 29 | 30 | setIsSubmitting(true); 31 | 32 | try { 33 | const response = await fetch(`/api/auth/two-factor/totp/setup`, { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | body: JSON.stringify({ 39 | password, 40 | }), 41 | }); 42 | const body = await response.json(); 43 | 44 | if (response.status === 200) { 45 | setDataUri(body.dataUri); 46 | setStep(SetupStep.DisplayQrCode); 47 | return; 48 | } 49 | 50 | if (body.error === ErrorCode.IncorrectPassword) { 51 | toast({ 52 | title: 'Incorrect Password', 53 | status: 'error', 54 | }); 55 | } else if (body.error) { 56 | toast({ 57 | title: 'Sorry something went wrong', 58 | status: 'error', 59 | }); 60 | } 61 | } catch (e) { 62 | toast({ 63 | title: 'Sorry something went wrong', 64 | status: 'error', 65 | }); 66 | } finally { 67 | setIsSubmitting(false); 68 | } 69 | } 70 | 71 | async function handleEnable(totpCode: string) { 72 | if (isSubmitting) { 73 | return; 74 | } 75 | 76 | setIsSubmitting(true); 77 | 78 | try { 79 | const response = await fetch(`/api/auth/two-factor/totp/enable`, { 80 | method: 'POST', 81 | headers: { 82 | 'Content-Type': 'application/json', 83 | }, 84 | body: JSON.stringify({ 85 | totpCode, 86 | }), 87 | }); 88 | const body = await response.json(); 89 | 90 | if (body.error === ErrorCode.IncorrectTwoFactorCode) { 91 | toast({ 92 | title: 'Incorrect code. Please try again', 93 | status: 'error', 94 | }); 95 | } else if (body.error) { 96 | toast({ 97 | title: 'Sorry something went wrong', 98 | status: 'error', 99 | }); 100 | } else { 101 | toast({ 102 | title: 'Successfully enabled 2FA', 103 | status: 'success', 104 | }); 105 | } 106 | 107 | onEnable(); 108 | } catch (e) { 109 | toast({ 110 | title: 'Sorry something went wrong', 111 | status: 'error', 112 | }); 113 | } finally { 114 | setIsSubmitting(false); 115 | } 116 | } 117 | 118 | return ( 119 | 120 | 121 | 122 | 123 | <> 124 | Enable two-factor authentication 125 | 126 | setPassword(event.target.value)} /> 127 | 128 | 129 | 132 | 135 | 136 | 137 | 138 | 139 | <> 140 | Enable two-factor authentication 141 | 142 | Scan the image below with the authenticator app on your phone or manually enter the text code instead. 143 | 144 | 145 | 146 | 149 | 152 | 153 | 154 | 155 | 156 | <> 157 | Enable two-factor authentication 158 | 159 | Enter your code to enable 2FA 160 | setTotpCode(val)} /> 161 | 162 | 163 | 166 | 169 | 170 | 171 | 172 | 173 | 174 | ); 175 | }; 176 | 177 | const DisableTwoFactSetupModal = ({ isOpen, onClose, onDisable }: { isOpen: boolean; onClose: () => void; onDisable: () => void }) => { 178 | const [totpCode, setTotpCode] = useState(''); 179 | const [isSubmitting, setIsSubmitting] = useState(false); 180 | const toast = useToast(); 181 | 182 | async function handleDisable() { 183 | if (isSubmitting) { 184 | return; 185 | } 186 | 187 | setIsSubmitting(true); 188 | 189 | try { 190 | const response = await fetch(`/api/auth/two-factor/totp/disable`, { 191 | method: 'POST', 192 | headers: { 193 | 'Content-Type': 'application/json', 194 | }, 195 | body: JSON.stringify({ 196 | totpCode, 197 | }), 198 | }); 199 | const body = await response.json(); 200 | 201 | if (body.error === ErrorCode.IncorrectTwoFactorCode) { 202 | toast({ 203 | title: 'Incorrect code. Please try again', 204 | status: 'error', 205 | }); 206 | } else if (body.error) { 207 | toast({ 208 | title: 'Sorry something went wrong', 209 | status: 'error', 210 | }); 211 | } else { 212 | toast({ 213 | title: 'Successfully disabled 2FA', 214 | status: 'success', 215 | }); 216 | } 217 | 218 | onDisable(); 219 | } catch (e) { 220 | toast({ 221 | title: 'Sorry something went wrong', 222 | status: 'error', 223 | }); 224 | } finally { 225 | setIsSubmitting(false); 226 | } 227 | } 228 | 229 | return ( 230 | 231 | 232 | 233 | Disable two-factor authentication 234 | 235 | Enter your code to disable 2FA 236 | setTotpCode(val)} /> 237 | 238 | 239 | 242 | 245 | 246 | 247 | 248 | ); 249 | }; 250 | 251 | export default function TwoFactSettings({ user }: { user: IUser }) { 252 | const { isOpen: isOpenSetupModal, onOpen: onOpenSetupModal, onClose: onCloseSetupModal } = useDisclosure(); 253 | const { isOpen: isOpenDisableModal, onOpen: onOpenDisableModal, onClose: onCloseDisableModal } = useDisclosure(); 254 | const [isEnabled, setEnabled] = useState(user.twoFactorEnabled); 255 | 256 | function handleOnEnable() { 257 | setEnabled(true); 258 | onCloseSetupModal(); 259 | } 260 | 261 | function handleOnDisable() { 262 | setEnabled(false); 263 | onCloseDisableModal(); 264 | } 265 | 266 | return ( 267 | 268 | 269 | 270 | Two factor authentication 271 | 272 | 273 | 274 | 275 | 276 | 277 | ); 278 | } 279 | --------------------------------------------------------------------------------