From 07bb3496e75672e0bd32cc409d87d0c42438ed6d Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 12 Dec 2022 20:42:16 -0500 Subject: [PATCH] Added the account recovery flow --- backend/src/models/token.ts | 32 +- frontend/components/basic/InputField.tsx | 2 - frontend/components/basic/Layout.tsx | 10 +- .../api/auth/EmailVerifyOnPasswordReset.ts | 34 ++ .../api/auth/SendEmailOnPasswordReset.ts | 33 ++ .../api/auth/getBackupEncryptedPrivateKey.ts | 26 ++ .../auth/resetPasswordOnAccountRecovery.ts | 50 +++ frontend/pages/email-not-verified.js | 22 ++ frontend/pages/{login.js => login.tsx} | 84 ++--- frontend/pages/password-reset.tsx | 290 ++++++++++++++++++ frontend/pages/signupinvite.js | 6 +- frontend/pages/verify-email.tsx | 94 ++++++ 12 files changed, 616 insertions(+), 67 deletions(-) create mode 100644 frontend/pages/api/auth/EmailVerifyOnPasswordReset.ts create mode 100644 frontend/pages/api/auth/SendEmailOnPasswordReset.ts create mode 100644 frontend/pages/api/auth/getBackupEncryptedPrivateKey.ts create mode 100644 frontend/pages/api/auth/resetPasswordOnAccountRecovery.ts create mode 100644 frontend/pages/email-not-verified.js rename frontend/pages/{login.js => login.tsx} (62%) create mode 100644 frontend/pages/password-reset.tsx create mode 100644 frontend/pages/verify-email.tsx diff --git a/backend/src/models/token.ts b/backend/src/models/token.ts index 9bf8836f5a..5481365a06 100644 --- a/backend/src/models/token.ts +++ b/backend/src/models/token.ts @@ -2,25 +2,25 @@ import { Schema, model } from 'mongoose'; import { EMAIL_TOKEN_LIFETIME } from '../config'; export interface IToken { - email: String; - token: String; - createdAt: Date; + email: string; + token: string; + createdAt: Date; } const tokenSchema = new Schema({ - email: { - type: String, - required: true - }, - token: { - type: String, - required: true - }, - createdAt: { - type: Date, - expires: parseInt(EMAIL_TOKEN_LIFETIME), - default: Date.now - } + email: { + type: String, + required: true + }, + token: { + type: String, + required: true + }, + createdAt: { + type: Date, + expires: parseInt(EMAIL_TOKEN_LIFETIME), + default: Date.now + } }); const Token = model('Token', tokenSchema); diff --git a/frontend/components/basic/InputField.tsx b/frontend/components/basic/InputField.tsx index 1415782142..46b42c15ca 100644 --- a/frontend/components/basic/InputField.tsx +++ b/frontend/components/basic/InputField.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { useRouter } from 'next/router'; import { faCircle, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -26,7 +25,6 @@ const InputField = ( Pick ) => { const [passwordVisible, setPasswordVisible] = useState(false); - const router = useRouter(); if (props.static === true) { return ( diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index 5057f70ce4..56a16cb3de 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -41,7 +41,7 @@ interface LayoutProps { export default function Layout({ children }: LayoutProps) { const router = useRouter(); const [workspaceList, setWorkspaceList] = useState([]); - const [workspaceMapping, setWorkspaceMapping] = useState([{ 1: 2 }]); + const [workspaceMapping, setWorkspaceMapping] = useState([{ '1': '2' }]); const [workspaceSelected, setWorkspaceSelected] = useState('∞'); const [newWorkspaceName, setNewWorkspaceName] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -221,20 +221,20 @@ export default function Layout({ children }: LayoutProps) { useEffect(() => { try { if ( - workspaceMapping[Number(workspaceSelected)] && - `${workspaceMapping[Number(workspaceSelected)]}` !== + workspaceMapping[workspaceSelected as any] && + `${workspaceMapping[workspaceSelected as any]}` !== router.asPath .split('/') [router.asPath.split('/').length - 1].split('?')[0] ) { router.push( '/dashboard/' + - workspaceMapping[Number(workspaceSelected)] + + workspaceMapping[workspaceSelected as any] + '?Development' ); localStorage.setItem( 'projectData.id', - `${workspaceMapping[Number(workspaceSelected)]}` + `${workspaceMapping[workspaceSelected as any]}` ); } } catch (error) { diff --git a/frontend/pages/api/auth/EmailVerifyOnPasswordReset.ts b/frontend/pages/api/auth/EmailVerifyOnPasswordReset.ts new file mode 100644 index 0000000000..b3d5e3d558 --- /dev/null +++ b/frontend/pages/api/auth/EmailVerifyOnPasswordReset.ts @@ -0,0 +1,34 @@ +interface Props { + email: string; + code: string; +} + +/** + * This is the second part of the account recovery step (a user needs to verify their email). + * A user need to click on a button in a magic link page + * @param {object} obj + * @param {object} obj.email - email of a user that is trying to recover access to their account + * @param {object} obj.code - token that a use received via the magic link + * @returns + */ +const EmailVerifyOnPasswordReset = async ({ email, code }: Props) => { + const response = await fetch('/api/v1/password/email/password-reset-verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: email, + code: code + }) + }); + if (response?.status === 200) { + return response; + } + + throw new Error( + 'Something went wrong during email verification on password reset.' + ); +}; + +export default EmailVerifyOnPasswordReset; diff --git a/frontend/pages/api/auth/SendEmailOnPasswordReset.ts b/frontend/pages/api/auth/SendEmailOnPasswordReset.ts new file mode 100644 index 0000000000..abe610f07d --- /dev/null +++ b/frontend/pages/api/auth/SendEmailOnPasswordReset.ts @@ -0,0 +1,33 @@ +interface Props { + email: string; +} + +/** + * This is the first of the account recovery step (a user needs to verify their email). + * It will send an email containing a magic link to start the account recovery flow. + * @param {object} obj + * @param {object} obj.email - email of a user that is trying to recover access to their account + * @returns + */ +const SendEmailOnPasswordReset = async ({ email }: Props) => { + const response = await fetch('/api/v1/password/email/password-reset', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: email + }) + }); + // need precise error handling about the status code + if (response?.status === 200) { + const data = await response.json(); + return data; + } + + throw new Error( + 'Something went wrong while sending the email verification for password reset.' + ); +}; + +export default SendEmailOnPasswordReset; diff --git a/frontend/pages/api/auth/getBackupEncryptedPrivateKey.ts b/frontend/pages/api/auth/getBackupEncryptedPrivateKey.ts new file mode 100644 index 0000000000..826085263e --- /dev/null +++ b/frontend/pages/api/auth/getBackupEncryptedPrivateKey.ts @@ -0,0 +1,26 @@ +/** + * This is the route that get an encrypted private key (will be decrypted with a backup key) + * @param {object} obj + * @param {object} obj.verificationToken - this is the token that confirms that a user is the right one + * @returns + */ +const getBackupEncryptedPrivateKey = ({ + verificationToken +}: { + verificationToken: string; +}) => { + return fetch('/api/v1/password/backup-private-key', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + verificationToken + } + }).then(async (res) => { + if (res?.status !== 200) { + console.log('Failed to get the backup key'); + } + return (await res?.json())?.backupPrivateKey; + }); +}; + +export default getBackupEncryptedPrivateKey; diff --git a/frontend/pages/api/auth/resetPasswordOnAccountRecovery.ts b/frontend/pages/api/auth/resetPasswordOnAccountRecovery.ts new file mode 100644 index 0000000000..f2b5ad98ea --- /dev/null +++ b/frontend/pages/api/auth/resetPasswordOnAccountRecovery.ts @@ -0,0 +1,50 @@ +interface Props { + verificationToken: string; + encryptedPrivateKey: string; + iv: string; + tag: string; + salt: string; + verifier: string; +} + +/** + * This is the route that resets the account password if all the previus steps were passed + * @param {object} obj + * @param {object} obj.verificationToken - this is the token that confirms that a user is the right one + * @param {object} obj.encryptedPrivateKey - the new encrypted private key (encrypted using the new password) + * @param {object} obj.iv + * @param {object} obj.tag + * @param {object} obj.salt + * @param {object} obj.verifier + * @returns + */ +const resetPasswordOnAccountRecovery = ({ + verificationToken, + encryptedPrivateKey, + iv, + tag, + salt, + verifier +}: Props) => { + return fetch('/api/v1/password/password-reset', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + verificationToken + }, + body: JSON.stringify({ + encryptedPrivateKey: encryptedPrivateKey, + iv: iv, + tag: tag, + salt: salt, + verifier: verifier + }) + }).then(async (res) => { + if (res?.status !== 200) { + console.log('Failed to get the backup key'); + } + return res; + }); +}; + +export default resetPasswordOnAccountRecovery; diff --git a/frontend/pages/email-not-verified.js b/frontend/pages/email-not-verified.js new file mode 100644 index 0000000000..11aec70bd4 --- /dev/null +++ b/frontend/pages/email-not-verified.js @@ -0,0 +1,22 @@ +import React from 'react'; +import Head from 'next/head'; + +export default function Activity() { + return ( +
+ + Request a New Invite + + +
+

Oops.

+

Your email was not verified.

+

Please try again.

+

+ Note: If it still {"doesn't work"}, please reach out to us at + support@infisical.com +

+
+
+ ); +} diff --git a/frontend/pages/login.js b/frontend/pages/login.tsx similarity index 62% rename from frontend/pages/login.js rename to frontend/pages/login.tsx index 2b5d6062c7..63dae65a76 100644 --- a/frontend/pages/login.js +++ b/frontend/pages/login.tsx @@ -1,34 +1,37 @@ -import React, { useEffect, useState } from "react"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { useEffect, useState } from 'react'; +import Head from 'next/head'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { faWarning } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import Button from "~/components/basic/buttons/Button"; -import Error from "~/components/basic/Error"; -import InputField from "~/components/basic/InputField"; -import attemptLogin from "~/utilities/attemptLogin"; +import Button from '~/components/basic/buttons/Button'; +import Error from '~/components/basic/Error'; +import InputField from '~/components/basic/InputField'; +import attemptLogin from '~/utilities/attemptLogin'; -import getWorkspaces from "./api/workspace/getWorkspaces"; +import getWorkspaces from './api/workspace/getWorkspaces'; export default function Login() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [errorLogin, setErrorLogin] = useState(false); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); - useEffect(async () => { - let userWorkspace; - try { - const userWorkspaces = await getWorkspaces(); - userWorkspace = userWorkspaces[0]._id; - router.push("/dashboard/" + userWorkspace); - } catch (error) { - console.log("Error - Not logged in yet"); - } + useEffect(() => { + const redirectToDashboard = async () => { + let userWorkspace; + try { + const userWorkspaces = await getWorkspaces(); + userWorkspace = userWorkspaces[0]._id; + router.push('/dashboard/' + userWorkspace); + } catch (error) { + console.log('Error - Not logged in yet'); + } + }; + redirectToDashboard(); }, []); /** @@ -73,23 +76,9 @@ export default function Login() {
-

- Log In +

+ Log in to your account

-
-

- Need an Infisical account? -

-
-
- - - -
-
+
+
+ Forgot password? +
{errorLogin && }
@@ -135,6 +127,16 @@ export default function Login() { solving it right now. Please come back in a few minutes.
)} +
+

+ Need an Infisical account? +

+ + + +
); } diff --git a/frontend/pages/password-reset.tsx b/frontend/pages/password-reset.tsx new file mode 100644 index 0000000000..a09b85ab08 --- /dev/null +++ b/frontend/pages/password-reset.tsx @@ -0,0 +1,290 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import Button from '~/components/basic/buttons/Button'; +import InputField from '~/components/basic/InputField'; +import passwordCheck from '~/components/utilities/checks/PasswordCheck'; +import Aes256Gcm from '~/components/utilities/cryptography/aes-256-gcm'; + +import EmailVerifyOnPasswordReset from './api/auth/EmailVerifyOnPasswordReset'; +import getBackupEncryptedPrivateKey from './api/auth/getBackupEncryptedPrivateKey'; +import resetPasswordOnAccountRecovery from './api/auth/resetPasswordOnAccountRecovery'; + +const queryString = require('query-string'); +const nacl = require('tweetnacl'); +const jsrp = require('jsrp'); +nacl.util = require('tweetnacl-util'); +const client = new jsrp.client(); + +export default function PasswordReset() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split('?')[1]); + const token = parsedUrl.token; + const email = parsedUrl.to?.replace(' ', '+').trim(); + const [verificationToken, setVerificationToken] = useState(''); + const [step, setStep] = useState(1); + const [backupKey, setBackupKey] = useState(''); + const [privateKey, setPrivateKey] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [backupKeyError, setBackupKeyError] = useState(false); + const [passwordErrorLength, setPasswordErrorLength] = useState(false); + const [passwordErrorNumber, setPasswordErrorNumber] = useState(false); + const [passwordErrorLowerCase, setPasswordErrorLowerCase] = useState(false); + + // Unencrypt the private key with a backup key + const getEncryptedKeyHandler = async () => { + try { + const result = await getBackupEncryptedPrivateKey({ verificationToken }); + setPrivateKey( + Aes256Gcm.decrypt({ + ciphertext: result.encryptedPrivateKey, + iv: result.iv, + tag: result.tag, + secret: backupKey + }) + ); + setStep(3); + } catch { + setBackupKeyError(true); + } + }; + + // If everything is correct, reset the password + const resetPasswordHandler = async () => { + let errorCheck = false; + errorCheck = passwordCheck({ + password: newPassword, + setPasswordErrorLength, + setPasswordErrorNumber, + setPasswordErrorLowerCase, + currentErrorCheck: errorCheck + }); + + if (!errorCheck) { + // Generate a random pair of a public and a private key + const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + text: privateKey, + secret: newPassword + .slice(0, 32) + .padStart( + 32 + + (newPassword.slice(0, 32).length - new Blob([newPassword]).size), + '0' + ) + }) as { ciphertext: string; iv: string; tag: string }; + + client.init( + { + username: email, + password: newPassword + }, + async () => { + client.createVerifier( + async (err: any, result: { salt: string; verifier: string }) => { + const response = await resetPasswordOnAccountRecovery({ + verificationToken, + encryptedPrivateKey: ciphertext, + iv, + tag, + salt: result.salt, + verifier: result.verifier + }); + + // if everything works, go the main dashboard page. + if (response?.status === 200) { + router.push('/login'); + } + } + ); + } + ); + } + }; + + // Click a button to confirm email + const stepConfirmEmail = ( +
+

+ Confirm your email +

+ verify email +
+
+
+ ); + + // Input backup key + const stepInputBackupKey = ( +
+

+ Enter your backup key +

+
+

+ You can find it in your emrgency kit. You had to download the enrgency + kit during signup. +

+
+
+ +
+
+
+
+
+
+ ); + + // Enter new password + const stepEnterNewPassword = ( +
+

+ Enter new password +

+
+

+ Make sure you save it somewhere save. +

+
+
+ { + setNewPassword(password); + passwordCheck({ + password, + setPasswordErrorLength, + setPasswordErrorNumber, + setPasswordErrorLowerCase, + currentErrorCheck: false + }); + }} + type="password" + value={newPassword} + isRequired + error={ + passwordErrorLength && passwordErrorLowerCase && passwordErrorNumber + } + autoComplete="new-password" + id="new-password" + /> +
+ {passwordErrorLength || passwordErrorLowerCase || passwordErrorNumber ? ( +
+
+ Password should contain at least: +
+
+ {passwordErrorLength ? ( + + ) : ( + + )} +
+ 14 characters +
+
+
+ {passwordErrorLowerCase ? ( + + ) : ( + + )} +
+ 1 lowercase character +
+
+
+ {passwordErrorNumber ? ( + + ) : ( + + )} +
+ 1 number +
+
+
+ ) : ( +
+ )} +
+
+
+
+
+ ); + + return ( +
+ {step === 1 && stepConfirmEmail} + {step === 2 && stepInputBackupKey} + {step === 3 && stepEnterNewPassword} +
+ ); +} diff --git a/frontend/pages/signupinvite.js b/frontend/pages/signupinvite.js index 26b6e1544f..1c1d9be370 100644 --- a/frontend/pages/signupinvite.js +++ b/frontend/pages/signupinvite.js @@ -33,7 +33,7 @@ export default function SignupInvite() { const [passwordErrorLowerCase, setPasswordErrorLowerCase] = useState(false); const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const [email, setEmail] = useState(parsedUrl.to); + const [email, setEmail] = useState(parsedUrl.to.replace(' ', '+').trim()); const token = parsedUrl.token; const [errorLogin, setErrorLogin] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -150,7 +150,7 @@ export default function SignupInvite() { width={410} alt="verify email" > -
+
+
+
+ )} + {step == 2 && ( +
+

+ Look for an email in your inbox. +

+
+

+ An email with instructions has been sent to {email}. +

+
+
+ )} + + ); +}