diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ad1acb1a6..b7d21525b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -4,6 +4,8 @@ const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; const SALT_ROUNDS = parseInt(process.env.SALT_ROUNDS!) || 10; const JWT_AUTH_LIFETIME = process.env.JWT_AUTH_LIFETIME! || '10d'; const JWT_AUTH_SECRET = process.env.JWT_AUTH_SECRET!; +const JWT_MFA_LIFETIME = process.env.JWT_MFA_LIFETIME! || '5m'; +const JWT_MFA_SECRET = process.env.JWT_MFA_SECRET!; const JWT_REFRESH_LIFETIME = process.env.JWT_REFRESH_LIFETIME! || '90d'; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!; const JWT_SERVICE_SECRET = process.env.JWT_SERVICE_SECRET!; @@ -54,6 +56,8 @@ export { SALT_ROUNDS, JWT_AUTH_LIFETIME, JWT_AUTH_SECRET, + JWT_MFA_LIFETIME, + JWT_MFA_SECRET, JWT_REFRESH_LIFETIME, JWT_REFRESH_SECRET, JWT_SERVICE_SECRET, diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 6e2de53b3..80b450c91 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -5,7 +5,7 @@ import * as Sentry from '@sentry/node'; import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); import { User, LoginSRPDetail } from '../../models'; -import { createToken, issueTokens, clearTokens } from '../../helpers/auth'; +import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth'; import { ACTION_LOGIN, ACTION_LOGOUT @@ -111,7 +111,7 @@ export const login2 = async (req: Request, res: Response) => { // compare server and client shared keys if (server.checkClientProof(clientProof)) { // issue tokens - const tokens = await issueTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ userId: user._id.toString() }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 60bcef912..9db83878b 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -5,11 +5,13 @@ import * as Sentry from '@sentry/node'; import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); import { User } from '../../models'; -import { issueTokens } from '../../helpers/auth'; +import { issueAuthTokens, createToken } from '../../helpers/auth'; import { sendMail } from '../../helpers/nodemailer'; import { TokenService } from '../../services'; import { - NODE_ENV + NODE_ENV, + JWT_MFA_LIFETIME, + JWT_MFA_SECRET } from '../../config'; import { TOKEN_EMAIL_MFA @@ -102,6 +104,15 @@ export const login2 = async (req: Request, res: Response) => { if (user.isMfaEnabled) { // case: user has MFA enabled + + // generate temporary MFA token + const token = createToken({ + payload: { + userId: user._id.toString() + }, + expiresIn: JWT_MFA_LIFETIME, + secret: JWT_MFA_SECRET + }); const code = await TokenService.createToken({ type: TOKEN_EMAIL_MFA, @@ -119,12 +130,13 @@ export const login2 = async (req: Request, res: Response) => { }); return res.status(200).send({ - mfaEnabled: true + mfaEnabled: true, + token }); } // issue tokens - const tokens = await issueTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ userId: user._id.toString() }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { @@ -136,18 +148,41 @@ export const login2 = async (req: Request, res: Response) => { // case: user does not have MFA enabled // return (access) token in response - return res.status(200).send({ + + interface ResponseData { + mfaEnabled: boolean; + encryptionVersion: any; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + } + + const response: ResponseData = { mfaEnabled: false, encryptionVersion: user.encryptionVersion, - protectedKey: user.protectedKey ?? null, - protectedKeyIV: user.protectedKeyIV ?? null, - protectedKeyTag: user.protectedKeyTag ?? null, token: tokens.token, publicKey: user.publicKey, encryptedPrivateKey: user.encryptedPrivateKey, iv: user.iv, tag: user.tag - }); + } + + if ( + user?.protectedKey && + user?.protectedKeyIV && + user?.protectedKeyTag + ) { + response.protectedKey = user.protectedKey; + response.protectedKeyIV = user.protectedKeyIV + response.protectedKeyTag = user.protectedKeyTag; + } + + return res.status(200).send(response); } return res.status(400).send({ @@ -164,6 +199,43 @@ export const login2 = async (req: Request, res: Response) => { } }; +/** + * Send MFA token to email [email] + * @param req + * @param res + */ +export const sendMfaToken = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + const code = await TokenService.createToken({ + type: TOKEN_EMAIL_MFA, + email + }); + + // send MFA code [code] to [email] + await sendMail({ + template: 'emailMfa.handlebars', + subjectLine: 'Infisical MFA code', + recipients: [email], + substitutions: { + code + } + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to send MFA code' + }); + } + + return res.status(200).send({ + message: 'Successfully sent new MFA code' + }); +} + /** * Verify MFA token [mfaToken] and issue JWT and refresh tokens if the * MFA token [mfaToken] is valid @@ -187,7 +259,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { if (!user) throw new Error('Failed to find user'); // issue tokens - const tokens = await issueTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ userId: user._id.toString() }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { @@ -196,7 +268,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { sameSite: 'strict', secure: NODE_ENV === 'production' ? true : false }); - + // case: user does not have MFA enabled // return (access) token in response return res.status(200).send({ @@ -217,4 +289,5 @@ export const verifyMfaToken = async (req: Request, res: Response) => { message: 'Failed to authenticate. Try again?' }); } -} \ No newline at end of file +} + diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts index db9d8a21b..8a26a9e9a 100644 --- a/backend/src/controllers/v2/signupController.ts +++ b/backend/src/controllers/v2/signupController.ts @@ -5,12 +5,10 @@ import { completeAccount } from '../../helpers/user'; import { initializeDefaultOrg } from '../../helpers/signup'; -import { issueTokens } from '../../helpers/auth'; +import { issueAuthTokens } from '../../helpers/auth'; import { INVITED, ACCEPTED } from '../../variables'; import axios from 'axios'; -// TODO: finish - /** * Complete setting up user by adding their personal and auth information as part of the * signup flow @@ -102,7 +100,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { ); // issue tokens - const tokens = await issueTokens({ + const tokens = await issueAuthTokens({ userId: user._id.toString() }); @@ -216,7 +214,7 @@ export const completeAccountInvite = async (req: Request, res: Response) => { ); // issue tokens - const tokens = await issueTokens({ + const tokens = await issueAuthTokens({ userId: user._id.toString() }); diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a8ca48377..f7f88ec0b 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -211,7 +211,7 @@ const getAuthAPIKeyPayload = async ({ * @return {String} obj.token - issued JWT token * @return {String} obj.refreshToken - issued refresh token */ -const issueTokens = async ({ userId }: { userId: string }) => { +const issueAuthTokens = async ({ userId }: { userId: string }) => { let token: string; let refreshToken: string; try { @@ -298,6 +298,6 @@ export { getAuthSTDPayload, getAuthAPIKeyPayload, createToken, - issueTokens, + issueAuthTokens, clearTokens }; diff --git a/backend/src/helpers/signup.ts b/backend/src/helpers/signup.ts index 3d2a397c9..dfca96736 100644 --- a/backend/src/helpers/signup.ts +++ b/backend/src/helpers/signup.ts @@ -2,9 +2,7 @@ import * as Sentry from '@sentry/node'; import { IUser } from '../models'; import { createOrganization } from './organization'; import { addMembershipsOrg } from './membershipOrg'; -import { createWorkspace } from './workspace'; -import { addMemberships } from './membership'; -import { OWNER, ADMIN, ACCEPTED } from '../variables'; +import { OWNER, ACCEPTED } from '../variables'; import { sendMail } from '../helpers/nodemailer'; import { TokenService } from '../services'; import { TOKEN_EMAIL_CONFIRMATION } from '../variables'; @@ -95,18 +93,6 @@ const initializeDefaultOrg = async ({ roles: [OWNER], statuses: [ACCEPTED] }); - - // initialize a default workspace inside the new organization - const workspace = await createWorkspace({ - name: `Example Project`, - organizationId: organization._id.toString() - }); - - await addMemberships({ - userIds: [user._id.toString()], - workspaceId: workspace._id.toString(), - roles: [ADMIN] - }); } catch (err) { throw new Error(`Failed to initialize default organization and workspace [err=${err}]`); } diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 86b653281..7e014103c 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,4 +1,5 @@ import requireAuth from './requireAuth'; +import requireMfaAuth from './requireMfaAuth'; import requireBotAuth from './requireBotAuth'; import requireSignupAuth from './requireSignupAuth'; import requireWorkspaceAuth from './requireWorkspaceAuth'; @@ -15,6 +16,7 @@ import validateRequest from './validateRequest'; export { requireAuth, + requireMfaAuth, requireBotAuth, requireSignupAuth, requireWorkspaceAuth, diff --git a/backend/src/middleware/requireMfaAuth.ts b/backend/src/middleware/requireMfaAuth.ts new file mode 100644 index 000000000..8fb914258 --- /dev/null +++ b/backend/src/middleware/requireMfaAuth.ts @@ -0,0 +1,43 @@ +import jwt from 'jsonwebtoken'; +import { Request, Response, NextFunction } from 'express'; +import { User } from '../models'; +import { JWT_MFA_SECRET } from '../config'; +import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; + +declare module 'jsonwebtoken' { + export interface UserIDJwtPayload extends jwt.JwtPayload { + userId: string; + } +} + +/** + * Validate if (MFA) JWT temporary token on request is valid (e.g. not expired) + * and if there is an associated user. + */ +const requireMfaAuth = async ( + req: Request, + res: Response, + next: NextFunction +) => { + // JWT (temporary) authentication middleware for complete signup + const [ AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE ] = <[string, string]>req.headers['authorization']?.split(' ', 2) ?? [null, null] + if(AUTH_TOKEN_TYPE === null) return next(BadRequestError({message: `Missing Authorization Header in the request header.`})) + if(AUTH_TOKEN_TYPE.toLowerCase() !== 'bearer') return next(BadRequestError({message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.`})) + if(AUTH_TOKEN_VALUE === null) return next(BadRequestError({message: 'Missing Authorization Body in the request header'})) + + const decodedToken = ( + jwt.verify(AUTH_TOKEN_VALUE, JWT_MFA_SECRET) + ); + + const user = await User.findOne({ + _id: decodedToken.userId + }).select('+publicKey'); + + if (!user) + return next(UnauthorizedRequestError({message: 'Unable to authenticate for User account completion. Try logging in again'})) + + req.user = user; + return next(); +}; + +export default requireMfaAuth; diff --git a/backend/src/routes/v2/auth.ts b/backend/src/routes/v2/auth.ts index 9f497a858..128a08fae 100644 --- a/backend/src/routes/v2/auth.ts +++ b/backend/src/routes/v2/auth.ts @@ -1,15 +1,15 @@ import express from 'express'; const router = express.Router(); import { body } from 'express-validator'; -import { validateRequest } from '../../middleware'; +import { requireMfaAuth, validateRequest } from '../../middleware'; import { authController } from '../../controllers/v2'; import { authLimiter } from '../../helpers/rateLimiter'; router.post( '/login1', authLimiter, - body('email').exists().trim().notEmpty(), - body('clientPublicKey').exists().trim().notEmpty(), + body('email').isString().trim().notEmpty(), + body('clientPublicKey').isString().trim().notEmpty(), validateRequest, authController.login1 ); @@ -17,19 +17,28 @@ router.post( router.post( '/login2', authLimiter, - body('email').exists().trim().notEmpty(), - body('clientProof').exists().trim().notEmpty(), + body('email').isString().trim().notEmpty(), + body('clientProof').isString().trim().notEmpty(), validateRequest, authController.login2 ); router.post( - '/mfa', - authLimiter, - body('email').exists().trim().notEmpty(), - body('mfaToken').exists().trim().notEmpty(), - validateRequest, - authController.verifyMfaToken + '/mfa/send', + authLimiter, + body('email').isString().trim().notEmpty(), + validateRequest, + authController.sendMfaToken +); + +router.post( + '/mfa/verify', + authLimiter, + requireMfaAuth, + body('email').isString().trim().notEmpty(), + body('mfaToken').isString().trim().notEmpty(), + validateRequest, + authController.verifyMfaToken ); export default router; \ No newline at end of file diff --git a/frontend/src/components/login/LoginStep.tsx b/frontend/src/components/login/LoginStep.tsx new file mode 100644 index 000000000..42d3e6d22 --- /dev/null +++ b/frontend/src/components/login/LoginStep.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { faWarning } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import Button from '@app/components/basic/buttons/Button'; +import Error from '@app/components/basic/Error'; +import InputField from '@app/components/basic/InputField'; +import attemptLogin from '@app/components/utilities/attemptLogin'; +import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; + +/** + * 1st step of login - user enters their username and password + * @param {Object} obj + * @param {String} obj.email - email of user + * @param {Function} obj.setEmail - function to set the email of user + * @param {String} obj.password - password of user + * @param {String} obj.setPassword - function to set the password of user + * @param {Function} obj.setStep - function to set the login flow step + * @returns + */ +export default function LoginStep ({ + email, + setEmail, + password, + setPassword, + setStep +}: { + email: string; + setEmail: (email: string) => void; + password: string; + setPassword: (password: string) => void; + setStep: (step: number) => void; +}) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [loginError, setLoginError] = useState(false); + + const { t } = useTranslation(); + + const handleLogin = async () => { + try { + if (!email || !password) { + return; + } + + setIsLoading(true); + const isLoginSuccessful = await attemptLogin(email, password); + if (isLoginSuccessful && isLoginSuccessful.success) { + // case: login was successful + + if (isLoginSuccessful.mfaEnabled) { + // case: login requires MFA step + setStep(2); + setIsLoading(false); + return; + } + + // case: login does not require MFA step + router.push(`/dashboard/${localStorage.getItem('projectData.id')}`); + } + + } catch (err) { + console.error(err); + setLoginError(true); + } + + setIsLoading(false); + } + + return ( +
e.preventDefault()}> +
+

+ {t('login:login')} +

+
+ +
+
+ +
+ + + +
+
+ {!isLoading && loginError && } +
+
+
+
+
+ {false && ( +
+ + {t('common:maintenance-alert')} +
+ )} +
+

+ {t('login:need-account')} +

+ + + +
+
+ ); +} + +export const getStaticProps = getTranslatedStaticProps(['auth', 'login']); \ No newline at end of file diff --git a/frontend/src/components/login/2FAStep.tsx b/frontend/src/components/login/MFAStep.tsx similarity index 50% rename from frontend/src/components/login/2FAStep.tsx rename to frontend/src/components/login/MFAStep.tsx index dfa9b6645..fa876ac5a 100644 --- a/frontend/src/components/login/2FAStep.tsx +++ b/frontend/src/components/login/MFAStep.tsx @@ -1,9 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ import React, { useState } from 'react'; import ReactCodeInput from 'react-code-input'; +import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import sendVerificationEmail from '@app/pages/api/auth/SendVerificationEmail'; +import attemptLoginMfa from '@app/components/utilities/attemptLoginMfa'; +import resendMfaToken from '@app/pages/api/auth/resendMfaToken'; import Button from '../basic/buttons/Button'; import Error from '../basic/Error'; @@ -27,63 +29,65 @@ const props = { borderColor: '#2d2f33' } } as const; -const propsPhone = { - inputStyle: { - fontFamily: 'monospace', - margin: '4px', - MozAppearance: 'textfield', - width: '40px', - borderRadius: '5px', - fontSize: '24px', - height: '40px', - paddingLeft: '7', - backgroundColor: '#0d1117', - color: 'white', - border: '1px solid #2d2f33', - textAlign: 'center', - outlineColor: '#8ca542', - borderColor: '#2d2f33' - } -} as const; - -interface CodeInputStepProps { - email: string; - incrementStep: () => void; - setCode: (value: string) => void; - codeError: boolean; -} /** - * This is the second step of sign up where users need to verify their email - * @param {object} obj - * @param {string} obj.email - user's email to which we just sent a verification email - * @param {function} obj.incrementStep - goes to the next step of signup - * @param {function} obj.setCode - state updating function that set the current value of the emai verification code - * @param {boolean} obj.codeError - whether the code was inputted wrong or now + * 2nd step of login - users enter their MFA code + * @param {Object} obj + * @param {String} obj.email - email of user + * @param {String} obj.password - password of user + * @param {Function} obj.setStep - function to set the login flow step * @returns */ -export default function TwoFAStep({ +export default function MFAStep({ email, - incrementStep, - setCode, - codeError -}: CodeInputStepProps): JSX.Element { + password +}: { + email: string; + password: string; +}): JSX.Element { + const router = useRouter(); const [isLoading, setIsLoading] = useState(false); - const [isResendingVerificationEmail, setIsResendingVerificationEmail] = useState(false); + const [mfaCode, setMfaCode] = useState(''); + const [codeError, setCodeError] = useState(false); + const { t } = useTranslation(); - const resendVerificationEmail = async () => { - setIsResendingVerificationEmail(true); - setIsLoading(true); - sendVerificationEmail(email); - setTimeout(() => { - setIsLoading(false); - setIsResendingVerificationEmail(false); - }, 2000); - }; + const handleLoginMfa = async () => { + try { + if (mfaCode.length !== 6) { + return; + } + + setIsLoading(true); + const isLoginSuccessful = await attemptLoginMfa({ + email, + password, + mfaToken: mfaCode + }); + + if (isLoginSuccessful) { + setIsLoading(false); + router.push(`/dashboard/${localStorage.getItem('projectData.id')}`); + } + + } catch (err) { + console.error(err); + setCodeError(true); + } + } + + const handleResendMfaCode = async () => { + try { + await resendMfaToken({ + email + }); + } catch (err) { + console.error(err); + } + } return ( -
+

{t('signup:step2-message')}

{email}

@@ -92,38 +96,31 @@ export default function TwoFAStep({ inputMode="tel" type="text" fields={6} - onChange={setCode} + onChange={setMfaCode} {...props} className="mt-6 mb-2" />
-
- -
{codeError && }
-
{t('signup:step2-resend-alert')} - @@ -131,6 +128,6 @@ export default function TwoFAStep({

{t('signup:step2-spam-alert')}

-
+ ); } diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index 248f9de90..e13ba01d7 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -1,7 +1,6 @@ import crypto from 'crypto'; import React, { useState } from 'react'; -import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -10,20 +9,21 @@ import nacl from 'tweetnacl'; import { encodeBase64 } from 'tweetnacl-util'; import completeAccountInformationSignup from '@app/pages/api/auth/CompleteAccountInformationSignup'; +import getOrganizations from '@app/pages/api/organization/getOrgs'; +import ProjectService from '@app/services/ProjectService'; import Button from '../basic/buttons/Button'; import InputField from '../basic/InputField'; -import attemptLogin from '../utilities/attemptLogin'; import passwordCheck from '../utilities/checks/PasswordCheck'; import Aes256Gcm from '../utilities/cryptography/aes-256-gcm'; import { deriveArgonKey } from '../utilities/cryptography/crypto'; import { saveTokenToLocalStorage } from '../utilities/saveTokenToLocalStorage'; +import SecurityClient from '../utilities/SecurityClient'; // eslint-disable-next-line new-cap const client = new jsrp.client(); interface UserInfoStepProps { - verificationToken: string; incrementStep: () => void; email: string; password: string; @@ -48,7 +48,6 @@ interface UserInfoStepProps { * @param {string} obj.setLastName - function managing the state of user's last name */ export default function UserInfoStep({ - verificationToken, incrementStep, email, password, @@ -66,7 +65,6 @@ export default function UserInfoStep({ const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); - const router = useRouter(); // Verifies if the information that the users entered (name, workspace) // is there, and if the password matches the criteria. @@ -110,6 +108,8 @@ export default function UserInfoStep({ async () => { client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => { try { + + // TODO: moduralize into KeyService const derivedKey = await deriveArgonKey({ password, salt: result.salt, @@ -158,28 +158,29 @@ export default function UserInfoStep({ encryptedPrivateKeyTag, salt: result.salt, verifier: result.verifier, - token: verificationToken, organizationName: `${firstName}'s organization` }); - // if everything works, go the main dashboard page. - if (response.status === 200) { - // response = await response.json(); + SecurityClient.setToken(response.token); - saveTokenToLocalStorage({ - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - privateKey - }); + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + privateKey + }); - await attemptLogin(email, password, () => {}, router, true, false); - incrementStep(); - } + incrementStep(); + + const userOrgs = await getOrganizations(); + await ProjectService.initProject({ + organizationId: userOrgs[0]?._id, + projectName: 'Example Project' + }); } catch (error) { setIsLoading(false); diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index e8d9f5e16..f6ab84715 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -1,20 +1,12 @@ /* eslint-disable prefer-destructuring */ -import crypto from 'crypto'; - import jsrp from 'jsrp'; -import { SecretDataProps } from 'public/data/frequentInterfaces'; -import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; import login1 from '@app/pages/api/auth/Login1'; import login2 from '@app/pages/api/auth/Login2'; -import addSecrets from '@app/pages/api/files/AddSecrets'; import getOrganizations from '@app/pages/api/organization/getOrgs'; import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserProjects'; -import getUser from '@app/pages/api/user/getUser'; -import uploadKeys from '@app/pages/api/workspace/uploadKeys'; +import KeyService from '@app/services/KeyService'; -import { deriveArgonKey, encryptAssymmetric } from './cryptography/crypto'; -import encryptSecrets from './secrets/encryptSecrets'; import Telemetry from './telemetry/Telemetry'; import { saveTokenToLocalStorage } from './saveTokenToLocalStorage'; import SecurityClient from './SecurityClient'; @@ -22,44 +14,39 @@ import SecurityClient from './SecurityClient'; // eslint-disable-next-line new-cap const client = new jsrp.client(); +interface IsLoginSuccessful { + mfaEnabled: boolean; + success: boolean; +} + /** - * This function logs in the user (whether it's right after signup, or a normal login) - * @param {string} email - email of the user logging in - * @param {string} password - password of the user logging in - * @param {function} setErrorLogin - function that visually dispay an error is something is wrong - * @param {*} router - * @param {boolean} isSignUp - whether this log in is a part of signup - * @param {boolean} isLogin - ? - * @returns + * Return whether or not login is successful for user with email [email] + * and password [password] + * @param {string} email - email of user to log in + * @param {string} password - password of user to log in */ const attemptLogin = async ( email: string, - password: string, - setErrorLogin: (value: boolean) => void, - router: any, - isSignUp: boolean, - isLogin: boolean -) => { - try { - const telemetry = new Telemetry().getInstance(); - + password: string +): Promise => { + const telemetry = new Telemetry().getInstance(); + return new Promise((resolve, reject) => { client.init( { username: email, password }, async () => { - const clientPublicKey = client.getPublicKey(); - try { + const clientPublicKey = client.getPublicKey(); const { serverPublicKey, salt } = await login1(email, clientPublicKey); client.setSalt(salt); client.setServerPublicKey(serverPublicKey); const clientProof = client.getProof(); // called M1 - // if everything works, go the main dashboard page. - const { // mfaEnabled + const { + mfaEnabled, encryptionVersion, protectedKey, protectedKeyIV, @@ -73,52 +60,40 @@ const attemptLogin = async ( email, clientProof ); + + if (mfaEnabled) { + // case: MFA is enabled - SecurityClient.setToken(token); + // set temporary (MFA) JWT token + SecurityClient.setToken(token); - let privateKey; - if (encryptionVersion === 1) { - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') + resolve({ + mfaEnabled, + success: true }); - - saveTokenToLocalStorage({ - publicKey, + } else if ( + !mfaEnabled && + encryptionVersion && + encryptedPrivateKey && + iv && + tag && + token + ) { + // case: MFA is not enabled + + // set JWT token + SecurityClient.setToken(token); + + const privateKey = await KeyService.decryptPrivateKey({ + encryptionVersion, encryptedPrivateKey, iv, tag, - privateKey - }); - } else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) { - const derivedKey = await deriveArgonKey({ password, salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); - - if (!derivedKey) throw new Error('Failed to derive key'); - - const key = Aes256Gcm.decrypt({ - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag, - secret: Buffer.from(derivedKey.hash) - }); - - // decrypt back the private key - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: Buffer.from(key, 'hex') + protectedKey, + protectedKeyIV, + protectedKeyTag }); saveTokenToLocalStorage({ @@ -131,155 +106,160 @@ const attemptLogin = async ( tag, privateKey }); - } - - if (!privateKey) throw new Error('Failed to decrypt private key'); + + // TODO: in the future - move this logic elsewhere + // because this function is about logging the user in + // and not initializing the login details + const userOrgs = await getOrganizations(); + const orgId = userOrgs[0]._id; + localStorage.setItem('orgData.id', orgId); - const userOrgs = await getOrganizations(); - const userOrgsData = userOrgs.map((org: { _id: string }) => org._id); - - let orgToLogin; - if (userOrgsData.includes(localStorage.getItem('orgData.id'))) { - orgToLogin = localStorage.getItem('orgData.id'); - } else { - orgToLogin = userOrgsData[0]; - localStorage.setItem('orgData.id', orgToLogin); - } - - let orgUserProjects = await getOrganizationUserProjects({ - orgId: orgToLogin - }); - - orgUserProjects = orgUserProjects?.map((project: { _id: string }) => project._id); - let projectToLogin; - if (orgUserProjects.includes(localStorage.getItem('projectData.id'))) { - projectToLogin = localStorage.getItem('projectData.id'); - } else { - try { - projectToLogin = orgUserProjects[0]; - localStorage.setItem('projectData.id', projectToLogin); - } catch (error) { - console.log('ERROR: User likely has no projects. ', error); - } - } - - if (email) { - telemetry.identify(email); - telemetry.capture('User Logged In'); - } - - if (isSignUp) { - const randomBytes = crypto.randomBytes(16).toString('hex'); - const PRIVATE_KEY = String(localStorage.getItem('PRIVATE_KEY')); - - const myUser = await getUser(); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: myUser.publicKey, - privateKey: PRIVATE_KEY - }) as { ciphertext: string; nonce: string }; - - await uploadKeys(projectToLogin, myUser._id, ciphertext, nonce); - - const secretsToBeAdded: SecretDataProps[] = [ - { - pos: 0, - key: 'DATABASE_URL', - // eslint-disable-next-line no-template-curly-in-string - value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net', - valueOverride: undefined, - comment: 'Secret referencing example', - id: '', - tags: [] - }, - { - pos: 1, - key: 'DB_USERNAME', - value: 'OVERRIDE_THIS', - valueOverride: undefined, - comment: - 'Override secrets with personal value', - id: '', - tags: [] - }, - { - pos: 2, - key: 'DB_PASSWORD', - value: 'OVERRIDE_THIS', - valueOverride: undefined, - comment: - 'Another secret override', - id: '', - tags: [] - }, - { - pos: 3, - key: 'DB_USERNAME', - value: 'user1234', - valueOverride: 'user1234', - comment: '', - id: '', - tags: [] - }, - { - pos: 4, - key: 'DB_PASSWORD', - value: 'example_password', - valueOverride: 'example_password', - comment: '', - id: '', - tags: [] - }, - { - pos: 5, - key: 'TWILIO_AUTH_TOKEN', - value: 'example_twillio_token', - valueOverride: undefined, - comment: '', - id: '', - tags: [] - }, - { - pos: 6, - key: 'WEBSITE_URL', - value: 'http://localhost:3000', - valueOverride: undefined, - comment: '', - id: '', - tags: [] - } - ]; - const secrets = await encryptSecrets({ - secretsToEncrypt: secretsToBeAdded, - workspaceId: String(localStorage.getItem('projectData.id')), - env: 'dev' + const orgUserProjects = await getOrganizationUserProjects({ + orgId }); - await addSecrets({ - secrets: secrets ?? [], - env: 'dev', - workspaceId: String(localStorage.getItem('projectData.id')) + localStorage.setItem('projectData.id', orgUserProjects[0]._id); + + // // TODO: this part definitely needs to be refactored + // const userOrgs = await getOrganizations(); + // const userOrgsData = userOrgs.map((org: { _id: string }) => org._id); + + // let orgToLogin; + // if (userOrgsData.includes(localStorage.getItem('orgData.id'))) { + // orgToLogin = localStorage.getItem('orgData.id'); + // } else { + // orgToLogin = userOrgsData[0]; + // localStorage.setItem('orgData.id', orgToLogin); + // } + + // let orgUserProjects = await getOrganizationUserProjects({ + // orgId: orgToLogin + // }); + + // orgUserProjects = orgUserProjects?.map((project: { _id: string }) => project._id); + // let projectToLogin; + // if (orgUserProjects.includes(localStorage.getItem('projectData.id'))) { + // projectToLogin = localStorage.getItem('projectData.id'); + // } else { + // try { + // projectToLogin = orgUserProjects[0]; + // localStorage.setItem('projectData.id', projectToLogin); + // } catch (error) { + // console.log('ERROR: User likely has no projects. ', error); + // } + // } + + if (email) { + telemetry.identify(email); + telemetry.capture('User Logged In'); + } + + resolve({ + mfaEnabled: false, + success: true }); } - - if (isLogin) { - if (localStorage.getItem('projectData.id') !== "undefined") { - router.push(`/dashboard/${localStorage.getItem('projectData.id')}`); - } else { - router.push("/noprojects"); - } - } - } catch (error) { - console.log(error); - setErrorLogin(true); - console.log('Login response not available'); + } catch (err) { + reject(err); } } ); - } catch (error) { - console.log('Something went wrong during authentication'); - } - return true; + }); }; export default attemptLogin; + +// should be function: init first project + +// if (isSignUp) { +// const randomBytes = crypto.randomBytes(16).toString('hex'); +// const PRIVATE_KEY = String(localStorage.getItem('PRIVATE_KEY')); + +// const myUser = await getUser(); + +// const { ciphertext, nonce } = encryptAssymmetric({ +// plaintext: randomBytes, +// publicKey: myUser.publicKey, +// privateKey: PRIVATE_KEY +// }) as { ciphertext: string; nonce: string }; + +// await uploadKeys(projectToLogin, myUser._id, ciphertext, nonce); + +// const secretsToBeAdded: SecretDataProps[] = [ +// { +// pos: 0, +// key: 'DATABASE_URL', +// // eslint-disable-next-line no-template-curly-in-string +// value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net', +// valueOverride: undefined, +// comment: 'Secret referencing example', +// id: '', +// tags: [] +// }, +// { +// pos: 1, +// key: 'DB_USERNAME', +// value: 'OVERRIDE_THIS', +// valueOverride: undefined, +// comment: +// 'Override secrets with personal value', +// id: '', +// tags: [] +// }, +// { +// pos: 2, +// key: 'DB_PASSWORD', +// value: 'OVERRIDE_THIS', +// valueOverride: undefined, +// comment: +// 'Another secret override', +// id: '', +// tags: [] +// }, +// { +// pos: 3, +// key: 'DB_USERNAME', +// value: 'user1234', +// valueOverride: 'user1234', +// comment: '', +// id: '', +// tags: [] +// }, +// { +// pos: 4, +// key: 'DB_PASSWORD', +// value: 'example_password', +// valueOverride: 'example_password', +// comment: '', +// id: '', +// tags: [] +// }, +// { +// pos: 5, +// key: 'TWILIO_AUTH_TOKEN', +// value: 'example_twillio_token', +// valueOverride: undefined, +// comment: '', +// id: '', +// tags: [] +// }, +// { +// pos: 6, +// key: 'WEBSITE_URL', +// value: 'http://localhost:3000', +// valueOverride: undefined, +// comment: '', +// id: '', +// tags: [] +// } +// ]; +// const secrets = await encryptSecrets({ +// secretsToEncrypt: secretsToBeAdded, +// workspaceId: String(localStorage.getItem('projectData.id')), +// env: 'dev' +// }); +// await addSecrets({ +// secrets: secrets ?? [], +// env: 'dev', +// workspaceId: String(localStorage.getItem('projectData.id')) +// }); + // } \ No newline at end of file diff --git a/frontend/src/components/utilities/attemptLoginMfa.ts b/frontend/src/components/utilities/attemptLoginMfa.ts new file mode 100644 index 000000000..318c33168 --- /dev/null +++ b/frontend/src/components/utilities/attemptLoginMfa.ts @@ -0,0 +1,88 @@ +/* eslint-disable prefer-destructuring */ +import jsrp from 'jsrp'; + +import login1 from '@app/pages/api/auth/Login1'; +import verifyMfaToken from '@app/pages/api/auth/verifyMfaToken'; +import KeyService from '@app/services/KeyService'; + +import { saveTokenToLocalStorage } from './saveTokenToLocalStorage'; +import SecurityClient from './SecurityClient'; + +// eslint-disable-next-line new-cap +const client = new jsrp.client(); + +/** + * Return whether or not MFA-login is successful for user with email [email] + * and MFA token [mfaToken] + * @param {Object} obj + * @param {String} obj.email - email of user + * @param {String} obj.mfaToken - MFA code/token + */ +const attemptLoginMfa = async ({ + email, + password, + mfaToken +}: { + email: string; + password: string; + mfaToken: string; +}): Promise => { + return new Promise((resolve, reject) => { + client.init({ + username: email, + password + }, async () => { + try { + const clientPublicKey = client.getPublicKey(); + const { salt } = await login1(email, clientPublicKey); + + const { + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, + token, + publicKey, + encryptedPrivateKey, + iv, + tag + } = await verifyMfaToken({ + email, + mfaToken + }); + + // set JWT token + SecurityClient.setToken(token); + + const privateKey = await KeyService.decryptPrivateKey({ + encryptionVersion, + encryptedPrivateKey, + iv, + tag, + password, + salt, + protectedKey, + protectedKeyIV, + protectedKeyTag + }); + + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv, + tag, + privateKey + }); + + resolve(true); + } catch (err) { + reject(err); + } + }); + }); +} + +export default attemptLoginMfa; \ No newline at end of file diff --git a/frontend/src/helpers/key.ts b/frontend/src/helpers/key.ts new file mode 100644 index 000000000..60a117927 --- /dev/null +++ b/frontend/src/helpers/key.ts @@ -0,0 +1,85 @@ +import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; +import { deriveArgonKey } from '@app/components/utilities/cryptography/crypto'; + +/** + * @param {Object} obj + * @param {Number} obj.encryptionVersion + * @param {String} obj.encryptedPrivateKey + * @param {String} obj.iv + * @param {String} obj.tag + * @param {String} obj.password + * @param {String} obj.salt + * @param {String} obj.protectedKey + * @param {String} obj.protectedKeyIV + * @param {String} obj.protectedKeyTag + */ +const decryptPrivateKeyHelper = async ({ + encryptionVersion, + encryptedPrivateKey, + iv, + tag, + password, + salt, + protectedKey, + protectedKeyIV, + protectedKeyTag, +}: { + encryptionVersion: number; + encryptedPrivateKey: string; + iv: string; + tag: string; + password: string; + salt: string; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; +}) => { +let privateKey; + try { + if (encryptionVersion === 1) { + privateKey = Aes256Gcm.decrypt({ + ciphertext: encryptedPrivateKey, + iv, + tag, + secret: password + .slice(0, 32) + .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') + }); + } else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) { + const derivedKey = await deriveArgonKey({ + password, + salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to generate derived key'); + + const key = Aes256Gcm.decrypt({ + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag, + secret: Buffer.from(derivedKey.hash) + }); + + // decrypt back the private key + privateKey = Aes256Gcm.decrypt({ + ciphertext: encryptedPrivateKey, + iv, + tag, + secret: Buffer.from(key, 'hex') + }); + } else { + throw new Error('Insufficient details to decrypt private key'); + } + } catch (err) { + console.error(err); + throw new Error('Failed to decrypt private key'); + } + + return privateKey; +} + +export { decryptPrivateKeyHelper }; \ No newline at end of file diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts new file mode 100644 index 000000000..8f6d02102 --- /dev/null +++ b/frontend/src/helpers/project.ts @@ -0,0 +1,140 @@ +import crypto from 'crypto'; + +import { encryptAssymmetric } from '@app/components/utilities/cryptography/crypto'; +import encryptSecrets from '@app/components/utilities/secrets/encryptSecrets'; +import addSecrets from '@app/pages/api/files/AddSecrets'; +import getUser from '@app/pages/api/user/getUser'; +import createWorkspace from "@app/pages/api/workspace/createWorkspace"; +import uploadKeys from '@app/pages/api/workspace/uploadKeys'; + +const secretsToBeAdded = [ + { + pos: 0, + key: 'DATABASE_URL', + // eslint-disable-next-line no-template-curly-in-string + value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net', + valueOverride: undefined, + comment: 'Secret referencing example', + id: '', + tags: [] + }, + { + pos: 1, + key: 'DB_USERNAME', + value: 'OVERRIDE_THIS', + valueOverride: undefined, + comment: + 'Override secrets with personal value', + id: '', + tags: [] + }, + { + pos: 2, + key: 'DB_PASSWORD', + value: 'OVERRIDE_THIS', + valueOverride: undefined, + comment: + 'Another secret override', + id: '', + tags: [] + }, + { + pos: 3, + key: 'DB_USERNAME', + value: 'user1234', + valueOverride: 'user1234', + comment: '', + id: '', + tags: [] + }, + { + pos: 4, + key: 'DB_PASSWORD', + value: 'example_password', + valueOverride: 'example_password', + comment: '', + id: '', + tags: [] + }, + { + pos: 5, + key: 'TWILIO_AUTH_TOKEN', + value: 'example_twillio_token', + valueOverride: undefined, + comment: '', + id: '', + tags: [] + }, + { + pos: 6, + key: 'WEBSITE_URL', + value: 'http://localhost:3000', + valueOverride: undefined, + comment: '', + id: '', + tags: [] + } +]; + +/** + * Create and initialize a new project in organization with id [organizationId] + * Note: current user should be a member of the organization + * @param {Object} obj + * @param {String} obj.organizationId - id of organization + * @param {String} obj.projectName - name of new project + * @returns {Project} project - new project + */ +const initProjectHelper = async ({ + organizationId, + projectName +}: { + organizationId: string; + projectName: string; +}) => { + let project; + try { + // create new project + project = await createWorkspace({ + workspaceName: projectName, + organizationId + }); + + // create and upload new (encrypted) project key + const randomBytes = crypto.randomBytes(16).toString('hex'); + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + if (!PRIVATE_KEY) throw new Error('Failed to find private key'); + + const user = await getUser(); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: randomBytes, + publicKey: user.publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadKeys(project._id, user._id, ciphertext, nonce); + + // encrypt and upload secrets to new project + const secrets = await encryptSecrets({ + secretsToEncrypt: secretsToBeAdded, + workspaceId: project._id, + env: 'dev' + }); + + await addSecrets({ + secrets: secrets ?? [], + env: 'dev', + workspaceId: project._id + }); + + } catch (err) { + console.error('Failed to init project in organization', err); + } + + return project; +} + +export { + initProjectHelper +} \ No newline at end of file diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 6f7552d54..1b17cd023 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -1,15 +1,23 @@ import { UserWsKeyPair } from '../keys/types'; export type User = { - seenIps: string[]; - _id: string; - email: string; createdAt: Date; updatedAt: Date; - __v: number; - firstName: string; - lastName: string; + email?: string; + firstName?: string; + lastName?: string; + encryptionVersion?: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; publicKey: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + isMfaEnabled: boolean; + seenIps: string[]; + _id: string; + __v: number; }; export type OrgUser = { diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts index 3236bdabd..a76307569 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts @@ -1,3 +1,5 @@ +import SecurityClient from '@app/components/utilities/SecurityClient'; + interface Props { email: string; firstName: string; @@ -12,7 +14,6 @@ interface Props { organizationName: string; salt: string; verifier: string; - token: string; } /** @@ -32,7 +33,6 @@ interface Props { * @param {string} obj.tag * @param {string} obj.salt * @param {string} obj.verifier - * @param {string} obj.token - token that confirms a user's identity * @returns */ const completeAccountInformationSignup = ({ @@ -48,13 +48,11 @@ const completeAccountInformationSignup = ({ encryptedPrivateKeyTag, salt, verifier, - token, organizationName -}: Props) => fetch('/api/v2/signup/complete-account/signup', { +}: Props) => SecurityClient.fetchCall('/api/v2/signup/complete-account/signup', { method: 'POST', headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${ token}` + 'Content-Type': 'application/json' }, body: JSON.stringify({ email, @@ -71,6 +69,12 @@ const completeAccountInformationSignup = ({ verifier, organizationName }) + }).then(async (res) => { + if (res && res?.status === 200) { + return res.json(); + } + console.log('Failed to verify MFA code'); + throw new Error('Something went wrong during MFA code verification'); }); export default completeAccountInformationSignup; diff --git a/frontend/src/pages/api/auth/Login1.ts b/frontend/src/pages/api/auth/Login1.ts index 30f2656f7..bb7afc82b 100644 --- a/frontend/src/pages/api/auth/Login1.ts +++ b/frontend/src/pages/api/auth/Login1.ts @@ -25,7 +25,7 @@ const login1 = async (email: string, clientPublicKey: string) => { const data = (await response.json()) as unknown as Login1; return data; } - + throw new Error("Wrong password"); }; diff --git a/frontend/src/pages/api/auth/Login2.ts b/frontend/src/pages/api/auth/Login2.ts index 7443830f8..e3d625950 100644 --- a/frontend/src/pages/api/auth/Login2.ts +++ b/frontend/src/pages/api/auth/Login2.ts @@ -1,14 +1,14 @@ interface Login2Response { mfaEnabled: boolean; - encryptionVersion: number; + token: string; + encryptionVersion?: number; protectedKey?: string; protectedKeyIV?: string; protectedKeyTag?: string; - token: string; - publicKey: string; - encryptedPrivateKey: string; - iv: string; - tag: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; } /** diff --git a/frontend/src/pages/api/auth/resendMfaToken.ts b/frontend/src/pages/api/auth/resendMfaToken.ts new file mode 100644 index 000000000..40f567f90 --- /dev/null +++ b/frontend/src/pages/api/auth/resendMfaToken.ts @@ -0,0 +1,29 @@ +import SecurityClient from "@app/components/utilities/SecurityClient"; + +/** + * Send new MFA token to user with email [email] + * @param {object} obj + * @param {string} obj.email - email of user + * @returns + */ +const resendMfaToken = async ({ + email, +}: { + email: string; +}) => SecurityClient.fetchCall('/api/v2/auth/mfa/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email + }) + }).then(async (res) => { + if (res && res?.status === 200) { + return res.json(); + } + console.log('Failed to send new MFA code'); + throw new Error('Something went wrong while sending new MFA code'); + }); + +export default resendMfaToken; diff --git a/frontend/src/pages/api/auth/verifyMfaToken.ts b/frontend/src/pages/api/auth/verifyMfaToken.ts new file mode 100644 index 000000000..8df09a826 --- /dev/null +++ b/frontend/src/pages/api/auth/verifyMfaToken.ts @@ -0,0 +1,35 @@ +import SecurityClient from "@app/components/utilities/SecurityClient"; + +/** + * Verify MFA token [mfaToken] for user with email [email] + * @param {object} obj + * @param {string} obj.email - email of user + * @param {string} obj.mfaToken - MFA cod/token to verify + * @returns + */ +const verifyMfaToken = async ({ + email, + mfaToken +}: { + email: string; + mfaToken: string; +}) => SecurityClient.fetchCall('/api/v2/auth/mfa/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email, + mfaToken + }) + }).then(async (res) => { + if (res && res?.status === 200) { + return res.json(); + } + console.log('Failed to verify MFA code'); + throw new Error('Something went wrong during MFA code verification'); + }); + + + +export default verifyMfaToken; diff --git a/frontend/src/pages/api/user/updateMyMfaEnabled.ts b/frontend/src/pages/api/user/updateMyMfaEnabled.ts new file mode 100644 index 000000000..a6c97f70b --- /dev/null +++ b/frontend/src/pages/api/user/updateMyMfaEnabled.ts @@ -0,0 +1,32 @@ +import SecurityClient from '@app/components/utilities/SecurityClient'; + +interface Props { + isMfaEnabled: boolean; +} + +/** + * Update the user's MFA-enabled status to [isMfaEnabled] + * @param {Object} obj + * @param {Boolean} obj.isMfaEnabled - whether or not MFA status should be set to enabled or not + * @returns {User} user - user with updated MFA-enabled status + */ +const updateMyMfaEnabled = async ({ + isMfaEnabled +}: Props) => + SecurityClient.fetchCall(`/api/v2/users/me/mfa`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + isMfaEnabled, + }) + }).then(async (res) => { + if (res && res.status === 200) { + return (await res.json()).user; + } + console.log('Failed to update MFA status'); + return undefined; + }); + +export default updateMyMfaEnabled; \ No newline at end of file diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 319032df7..f08bd325b 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -4,28 +4,24 @@ import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { faWarning } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import Button from '@app/components/basic/buttons/Button'; -import Error from '@app/components/basic/Error'; -import InputField from '@app/components/basic/InputField'; import ListBox from '@app/components/basic/Listbox'; -import attemptLogin from '@app/components/utilities/attemptLogin'; +import LoginStep from '@app/components/login/LoginStep'; +import MFAStep from '@app/components/login/MFAStep'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; import { isLoggedIn } from '@app/reactQuery'; import getWorkspaces from './api/workspace/getWorkspaces'; export default function Login() { + const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [errorLogin, setErrorLogin] = useState(false); - const [isLoading, setIsLoading] = useState(false); const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false); - const router = useRouter(); + const [step, setStep] = useState(1); const { t } = useTranslation(); const lang = router.locale ?? 'en'; + const setLanguage = async (to: string) => { router.push('/login', '/login', { locale: to }); @@ -49,25 +45,32 @@ export default function Login() { redirectToDashboard(); } }, []); - - /** - * This function check if the user entered the correct credentials and should be allowed to log in. - */ - const loginCheck = async () => { - - // #TODO: IF 2FA IS ENABLED REDIRECT TO <2FASTEP /> AND 'return;' - - if (!email || !password) { - return; + + const renderStep = (loginStep: number) => { + // TODO: add MFA step + switch (loginStep) { + case 1: + return ( + + ); + case 2: + // TODO: add MFA step + return ( + + ); + default: + return
} - - setIsLoading(true); - await attemptLogin(email, password, setErrorLogin, router, false, true).then(() => { - setTimeout(() => { - setIsLoading(false); - }, 2000); - }); - }; + } if (isAlreadyLoggedIn) { return null @@ -87,80 +90,7 @@ export default function Login() { long logo
-
setErrorLogin(false)} onSubmit={(e) => e.preventDefault()}> -
-

- {t('login:login')} -

-
- -
-
- -
- - - -
-
- {!isLoading && errorLogin && } -
-
-
-
- {/*
-

I may have forgotten my password.

-
*/} -
- {false && ( -
- - {t('common:maintenance-alert')} -
- )} -
-

- {t('login:need-account')} -

- - - -
-
+ {renderStep(step)}
- console.log(value)} - /> +
diff --git a/frontend/src/pages/signup.tsx b/frontend/src/pages/signup.tsx index ae93d97d0..dbd610979 100644 --- a/frontend/src/pages/signup.tsx +++ b/frontend/src/pages/signup.tsx @@ -11,6 +11,7 @@ import DownloadBackupPDF from '@app/components/signup/DonwloadBackupPDFStep'; import EnterEmailStep from '@app/components/signup/EnterEmailStep'; import TeamInviteStep from '@app/components/signup/TeamInviteStep'; import UserInfoStep from '@app/components/signup/UserInfoStep'; +import SecurityClient from '@app/components/utilities/SecurityClient'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; import checkEmailVerificationCode from './api/auth/CheckEmailVerificationCode'; @@ -28,7 +29,6 @@ export default function SignUp() { const [codeError, setCodeError] = useState(false); const [step, setStep] = useState(1); const router = useRouter(); - const [verificationToken, setVerificationToken] = useState(''); const { t } = useTranslation(); @@ -59,7 +59,7 @@ export default function SignUp() { // Checking if the code matches the email. const response = await checkEmailVerificationCode({ email, code }); if (response.status === 200) { - setVerificationToken((await response.json()).token); + SecurityClient.setToken((await response.json()).token); setStep(3); } else { setCodeError(true); @@ -94,7 +94,6 @@ export default function SignUp() { /> ) : step === 3 ? ( void; -}; +import { useGetUser } from '../../../../hooks/api'; +import { User } from '../../../../hooks/api/types'; +import updateMyMfaEnabled from '../../../../pages/api/user/updateMyMfaEnabled'; -export const SecuritySection = ({ - isTwoFAEnabled, - onIsTwoFAEnabledChange -}: Props) => { +export const SecuritySection = () => { + const [isMfaEnabled, setIsMfaEnabled] = useState(false); + const { data: user } = useGetUser(); + + useEffect(() => { + if (user && typeof user.isMfaEnabled !== 'undefined') { + setIsMfaEnabled(user.isMfaEnabled); + } + }, [user]); + + const toggleMfa = async (state: boolean) => { + try { + const newUser: User = await updateMyMfaEnabled({ + isMfaEnabled: state + }); + + if (newUser) { + setIsMfaEnabled(newUser.isMfaEnabled); + } + } catch (err) { + console.error(err); + } + } + return (
@@ -18,9 +39,9 @@ export const SecuritySection = ({ { - onIsTwoFAEnabledChange(state as boolean); + toggleMfa(state as boolean); }} > Enable 2-factor authentication via your personal email.