From 5cadb9e2f93d6d9507a0fa70934c4bb36edf033c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 23 Jan 2023 22:10:15 +0700 Subject: [PATCH 01/11] Finish MFA v1 and refactor all tokens into separate TokenService with modified collection --- backend/src/app.ts | 4 +- .../controllers/v1/membershipOrgController.ts | 31 ++- .../src/controllers/v1/passwordController.ts | 29 +-- .../src/controllers/v1/signupController.ts | 2 +- backend/src/controllers/v2/authController.ts | 216 ++++++++++++++++++ backend/src/controllers/v2/index.ts | 2 + backend/src/controllers/v2/usersController.ts | 29 +++ backend/src/helpers/signup.ts | 26 +-- backend/src/helpers/token.ts | 162 +++++++++++++ backend/src/models/index.ts | 6 +- backend/src/models/token.ts | 33 --- backend/src/models/tokenData.ts | 57 +++++ backend/src/models/user.ts | 6 + backend/src/routes/v1/auth.ts | 4 +- backend/src/routes/v1/bot.ts | 2 +- backend/src/routes/v2/auth.ts | 35 +++ backend/src/routes/v2/index.ts | 2 + backend/src/routes/v2/users.ts | 14 +- backend/src/services/TokenService.ts | 69 ++++++ backend/src/services/index.ts | 4 +- backend/src/templates/emailMfa.handlebars | 19 ++ .../templates/emailVerification.handlebars | 8 +- backend/src/variables/index.ts | 14 ++ backend/src/variables/token.ts | 11 + backend/src/variables/user.ts | 5 + 25 files changed, 692 insertions(+), 98 deletions(-) create mode 100644 backend/src/controllers/v2/authController.ts create mode 100644 backend/src/helpers/token.ts delete mode 100644 backend/src/models/token.ts create mode 100644 backend/src/models/tokenData.ts create mode 100644 backend/src/routes/v2/auth.ts create mode 100644 backend/src/services/TokenService.ts create mode 100644 backend/src/templates/emailMfa.handlebars create mode 100644 backend/src/variables/token.ts create mode 100644 backend/src/variables/user.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 79561d00c..831a046ee 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const { patchRouterParam } = require('./utils/patchAsyncRoutes'); -import express, { Request, Response } from 'express'; +import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; @@ -42,6 +42,7 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { + auth as v2AuthRouter, users as v2UsersRouter, organizations as v2OrganizationsRouter, workspace as v2WorkspaceRouter, @@ -109,6 +110,7 @@ app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes +app.use('/api/v2/auth', v2AuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); app.use('/api/v2/workspace', v2EnvironmentRouter); diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index f3703b889..612f8708a 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -1,14 +1,13 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; import { SITE_URL, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; -import { MembershipOrg, Organization, User, Token } from '../../models'; +import { MembershipOrg, Organization, User } from '../../models'; import { deleteMembershipOrg as deleteMemberFromOrg } from '../../helpers/membershipOrg'; -import { checkEmailVerification } from '../../helpers/signup'; import { createToken } from '../../helpers/auth'; import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; import { sendMail } from '../../helpers/nodemailer'; -import { OWNER, ADMIN, MEMBER, ACCEPTED, INVITED } from '../../variables'; +import { TokenService } from '../../services'; +import { OWNER, ADMIN, MEMBER, ACCEPTED, INVITED, TOKEN_EMAIL_ORG_INVITATION } from '../../variables'; /** * Delete organization membership with id [membershipOrgId] from organization @@ -165,17 +164,11 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { const organization = await Organization.findOne({ _id: organizationId }); if (organization) { - const token = crypto.randomBytes(16).toString('hex'); - - await Token.findOneAndUpdate( - { email: inviteeEmail }, - { - email: inviteeEmail, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_ORG_INVITATION, + email: inviteeEmail, + organizationId: organization._id + }); await sendMail({ template: 'organizationInvitation.handlebars', @@ -227,10 +220,12 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => { if (!membershipOrg) throw new Error('Failed to find any invitations for email'); - - await checkEmailVerification({ + + await TokenService.validateToken({ + type: TOKEN_EMAIL_ORG_INVITATION, email, - code + organizationId: membershipOrg.organization, + token: code }); if (user && user?.publicKey) { diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 27d712a6b..6d10fcbbd 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -1,14 +1,14 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; // eslint-disable-next-line @typescript-eslint/no-var-requires const jsrp = require('jsrp'); import * as bigintConversion from 'bigint-conversion'; -import { User, Token, BackupPrivateKey } from '../../models'; -import { checkEmailVerification } from '../../helpers/signup'; +import { User, BackupPrivateKey } from '../../models'; import { createToken } from '../../helpers/auth'; import { sendMail } from '../../helpers/nodemailer'; +import { TokenService } from '../../services'; import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, SITE_URL } from '../../config'; +import { TOKEN_EMAIL_PASSWORD_RESET } from '../../variables'; const clientPublicKeys: any = {}; @@ -33,17 +33,10 @@ export const emailPasswordReset = async (req: Request, res: Response) => { }); } - const token = crypto.randomBytes(16).toString('hex'); - - await Token.findOneAndUpdate( - { email }, - { - email, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_PASSWORD_RESET, + email + }); await sendMail({ template: 'passwordReset.handlebars', @@ -55,7 +48,6 @@ export const emailPasswordReset = async (req: Request, res: Response) => { callback_url: SITE_URL + '/password-reset' } }); - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -88,10 +80,11 @@ export const emailPasswordResetVerify = async (req: Request, res: Response) => { error: 'Failed email verification for password reset' }); } - - await checkEmailVerification({ + + await TokenService.validateToken({ + type: TOKEN_EMAIL_PASSWORD_RESET, email, - code + token: code }); // generate temporary password-reset token diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 62e5a62a3..1d56ae0aa 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import { NODE_ENV, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; +import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; import { User, MembershipOrg } from '../../models'; import { completeAccount } from '../../helpers/user'; import { diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts new file mode 100644 index 000000000..c39939251 --- /dev/null +++ b/backend/src/controllers/v2/authController.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +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 { sendMail } from '../../helpers/nodemailer'; +import { TokenService } from '../../services'; +import { + NODE_ENV +} from '../../config'; +import { + TOKEN_EMAIL_MFA +} from '../../variables'; + +declare module 'jsonwebtoken' { + export interface UserIDJwtPayload extends jwt.JwtPayload { + userId: string; + } +} + +const clientPublicKeys: any = {}; + +/** + * Log in user step 1: Return [salt] and [serverPublicKey] as part of step 1 of SRP protocol + * @param req + * @param res + * @returns + */ +export const login1 = async (req: Request, res: Response) => { + try { + const { + email, + clientPublicKey + }: { email: string; clientPublicKey: string } = req.body; + + const user = await User.findOne({ + email + }).select('+salt +verifier'); + + if (!user) throw new Error('Failed to find user'); + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier + }, + () => { + // generate server-side public key + const serverPublicKey = server.getPublicKey(); + clientPublicKeys[email] = { + clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt) + }; + + return res.status(200).send({ + serverPublicKey, + salt: user.salt + }); + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to start authentication process' + }); + } +}; + +/** + * Log in user step 2: complete step 2 of SRP protocol and return token and their (encrypted) + * private key + * @param req + * @param res + * @returns + */ +export const login2 = async (req: Request, res: Response) => { + + // check to see if user has MFA enabled; if yes then issue MFA-token + // TODO: may have to figure out a better token system for tokens with varying expirations + // (e.g. for org-invitations vs. auth etc.) + try { + const { email, clientProof } = req.body; + const user = await User.findOne({ + email + }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + + if (!user) throw new Error('Failed to find user'); + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier, + b: clientPublicKeys[email].serverBInt + }, + async () => { + server.setClientPublicKey(clientPublicKeys[email].clientPublicKey); + + // compare server and client shared keys + if (server.checkClientProof(clientProof)) { + + if (user.isMfaEnabled) { + // case: user has MFA enabled + + 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 + } + }); + + return res.status(200).send({ + mfaEnabled: true + }); + } + + // issue tokens + const tokens = await issueTokens({ userId: user._id.toString() }); + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + 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({ + mfaEnabled: false, + token: tokens.token, + publicKey: user.publicKey, + encryptedPrivateKey: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag + }); + } + + return res.status(400).send({ + message: 'Failed to authenticate. Try again?' + }); + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to authenticate. Try again?' + }); + } +}; + +/** + * Verify MFA token [mfaToken] and issue JWT and refresh tokens if the + * MFA token [mfaToken] is valid + * @param req + * @param res + */ +export const verifyMfaToken = async (req: Request, res: Response) => { + try { + const { email, mfaToken } = req.body; + + await TokenService.validateToken({ + type: TOKEN_EMAIL_MFA, + email, + token: mfaToken + }); + + const user = await User.findOne({ + email + }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + + if (!user) throw new Error('Failed to find user'); + + // issue tokens + const tokens = await issueTokens({ userId: user._id.toString() }); + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + 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({ + token: tokens.token, + publicKey: user.publicKey, + encryptedPrivateKey: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to authenticate. Try again?' + }); + } +} \ No newline at end of file diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 936f5e281..7c5eaf180 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,3 +1,4 @@ +import * as authController from './authController'; import * as usersController from './usersController'; import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; @@ -8,6 +9,7 @@ import * as secretsController from './secretsController'; import * as environmentController from './environmentController'; export { + authController, usersController, organizationsController, workspaceController, diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 4ec1099d9..d66d2ced0 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -55,6 +55,35 @@ export const getMe = async (req: Request, res: Response) => { }); } +/** + * Update the current user's MFA-enabled status [isMfaEnabled]. + * Note: Infisical currently only supports email-based 2FA only; this will expand to + * include SMS and authenticator app modes of authentication in the future. + * @param req + * @param res + * @returns + */ +export const updateMyMfaEnabled = async (req: Request, res: Response) => { + let user; + try { + const { isMfaEnabled }: { isMfaEnabled: boolean } = req.body; + req.user.isMfaEnabled = isMfaEnabled; + await req.user.save(); + + user = req.user; + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to update current user's MFA status" + }); + } + + return res.status(200).send({ + user + }); +} + /** * Return organizations that the current user is part of. * @param req diff --git a/backend/src/helpers/signup.ts b/backend/src/helpers/signup.ts index 4621d1f50..d0b392e00 100644 --- a/backend/src/helpers/signup.ts +++ b/backend/src/helpers/signup.ts @@ -1,12 +1,13 @@ import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; -import { Token, IToken, IUser } from '../models'; +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 { sendMail } from '../helpers/nodemailer'; +import { TokenService } from '../services'; +import { TOKEN_EMAIL_CONFIRMATION } from '../variables'; /** * Send magic link to verify email to [email] @@ -14,21 +15,13 @@ import { sendMail } from '../helpers/nodemailer'; * @param {Object} obj * @param {String} obj.email - email * @returns {Boolean} success - whether or not operation was successful - * */ const sendEmailVerification = async ({ email }: { email: string }) => { try { - const token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); - - await Token.findOneAndUpdate( - { email }, - { - email, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_CONFIRMATION, + email + }); // send mail await sendMail({ @@ -62,12 +55,11 @@ const checkEmailVerification = async ({ code: string; }) => { try { - const token = await Token.findOneAndDelete({ + await TokenService.validateToken({ + type: TOKEN_EMAIL_CONFIRMATION, email, token: code }); - - if (!token) throw new Error('Failed to find email verification token'); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/helpers/token.ts b/backend/src/helpers/token.ts new file mode 100644 index 000000000..551e4eb44 --- /dev/null +++ b/backend/src/helpers/token.ts @@ -0,0 +1,162 @@ +import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { TokenData } from '../models'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} from '../variables'; +import { + SALT_ROUNDS +} from '../config'; +import { UnauthorizedRequestError } from '../utils/errors'; + +/** + * Create and store a token in the database for purpose [type] + * @param {Object} obj + * @param {String} obj.type + * @param {String} obj.email + * @param {String} obj.phoneNumber + * @param {Types.ObjectId} obj.organizationId + * @returns {String} token - the created token + */ +const createTokenHelper = async ({ + type, + email, + phoneNumber, + organizationId +}: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId +}) => { + let token, expiresAt; + try { + // generate random token based on specified token use-case + // type [type] + switch (type) { + case TOKEN_EMAIL_CONFIRMATION: + // generate random 6-digit code + token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); + expiresAt = new Date((new Date()).getTime() + 86400000); + break; + case TOKEN_EMAIL_MFA: + // generate random 6-digit code + token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); + expiresAt = new Date((new Date()).getTime() + 300000); + break; + case TOKEN_EMAIL_ORG_INVITATION: + // generate random hex + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date((new Date()).getTime() + 259200000); + break; + case TOKEN_EMAIL_PASSWORD_RESET: + // generate random hex + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date((new Date()).getTime() + 86400000); + break; + default: + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date(); + break; + } + + interface Query { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + } + + const query: Query = { type }; + + if (email) { query.email = email; } + if (phoneNumber) { query.phoneNumber = phoneNumber; } + if (organizationId) { query.organization = organizationId } + + await TokenData.findOneAndUpdate( + query, + { + type, + email, + phoneNumber, + organization: organizationId, + tokenHash: await bcrypt.hash(token, SALT_ROUNDS), + expiresAt + }, + { + new: true, + upsert: true + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error( + "Failed to create token" + ); + } + + return token; +} + +/** + * + * @param {Object} obj + * @param {String} obj.email - email associated with the token + * @param {String} obj.token - value of the token + */ +const validateTokenHelper = async ({ + type, + email, + phoneNumber, + organizationId, + token +}: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + token: string; +}) => { + try { + interface Query { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + } + + const query: Query = { type }; + + if (email) { query.email = email; } + if (phoneNumber) { query.phoneNumber = phoneNumber; } + if (organizationId) { query.organization = organizationId; } + + const tokenData = await TokenData.findOneAndDelete(query); + + if (!tokenData) throw new Error('Failed to find token to validate'); + + if (tokenData.expiresAt < new Date()) throw new Error('Token has expired'); + + const isValid = await bcrypt.compare(token, tokenData.tokenHash); + if (!isValid) throw UnauthorizedRequestError({ + message: 'Failed token data validation due to incorrect token' + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error( + "Failed to validate token data" + ); + } +} + +export { + createTokenHelper, + validateTokenHelper +} \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 72ffca607..ae2e84aaa 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -10,7 +10,7 @@ import MembershipOrg, { IMembershipOrg } from './membershipOrg'; import Organization, { IOrganization } from './organization'; import Secret, { ISecret } from './secret'; import ServiceToken, { IServiceToken } from './serviceToken'; -import Token, { IToken } from './token'; +import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; import Workspace, { IWorkspace } from './workspace'; @@ -42,8 +42,8 @@ export { ISecret, ServiceToken, IServiceToken, - Token, - IToken, + TokenData, + ITokenData, User, IUser, UserAction, diff --git a/backend/src/models/token.ts b/backend/src/models/token.ts deleted file mode 100644 index 9569aee0b..000000000 --- a/backend/src/models/token.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Schema, model } from 'mongoose'; -import { EMAIL_TOKEN_LIFETIME } from '../config'; - -export interface IToken { - email: string; - token: string; - createdAt: Date; -} - -const tokenSchema = new Schema({ - email: { - type: String, - required: true - }, - token: { - type: String, - required: true - }, - createdAt: { - type: Date, - default: Date.now - } -}); - -tokenSchema.index({ - createdAt: 1 -}, { - expireAfterSeconds: parseInt(EMAIL_TOKEN_LIFETIME) -}); - -const Token = model('Token', tokenSchema); - -export default Token; diff --git a/backend/src/models/tokenData.ts b/backend/src/models/tokenData.ts new file mode 100644 index 000000000..ad23d56aa --- /dev/null +++ b/backend/src/models/tokenData.ts @@ -0,0 +1,57 @@ +import { Schema, Types, model } from 'mongoose'; + +export interface ITokenData { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + tokenHash: string; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const tokenDataSchema = new Schema({ + type: { + type: String, + enum: [ + 'emailConfirmation', + 'emailMfa', + 'organizationInvitation', + 'passwordReset' + ], + required: true + }, + email: { + type: String + }, + phoneNumber: { + type: String + }, + organization: { // organizationInvitation-specific field + type: Schema.Types.ObjectId, + ref: 'Organization' + }, + tokenHash: { + type: String, + select: false, + required: true + }, + expiresAt: { + type: Date, + expires: 0, + required: true + } +}, { + timestamps: true +}); + +tokenDataSchema.index({ + expiresAt: 1 +}, { + expireAfterSeconds: 0 +}); + +const TokenData = model('TokenData', tokenDataSchema); + +export default TokenData; diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 7ea988c9d..fbf3b2791 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,4 +1,5 @@ import { Schema, model, Types } from 'mongoose'; +import { MFA_METHOD_EMAIL } from '../variables'; export interface IUser { _id: Types.ObjectId; @@ -12,6 +13,7 @@ export interface IUser { salt?: string; verifier?: string; refreshVersion?: number; + isMfaEnabled: boolean; } const userSchema = new Schema( @@ -54,6 +56,10 @@ const userSchema = new Schema( type: Number, default: 0, select: false + }, + isMfaEnabled: { + type: Boolean, + default: false } }, { diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index 99a65e4ef..638e4501b 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -7,7 +7,7 @@ import { authLimiter } from '../../helpers/rateLimiter'; router.post('/token', validateRequest, authController.getNewToken); -router.post( +router.post( // deprecated (moved to api/v2/auth/login1) '/login1', authLimiter, body('email').exists().trim().notEmpty(), @@ -16,7 +16,7 @@ router.post( authController.login1 ); -router.post( +router.post( // deprecated (moved to api/v2/auth/login2) '/login2', authLimiter, body('email').exists().trim().notEmpty(), diff --git a/backend/src/routes/v1/bot.ts b/backend/src/routes/v1/bot.ts index 1b98f48c5..4d3865562 100644 --- a/backend/src/routes/v1/bot.ts +++ b/backend/src/routes/v1/bot.ts @@ -31,7 +31,7 @@ router.patch( requireBotAuth({ acceptedRoles: [ADMIN, MEMBER] }), - body('isActive').isBoolean(), + body('isActive').exists().isBoolean(), body('botKey'), validateRequest, botController.setBotActiveState diff --git a/backend/src/routes/v2/auth.ts b/backend/src/routes/v2/auth.ts new file mode 100644 index 000000000..9f497a858 --- /dev/null +++ b/backend/src/routes/v2/auth.ts @@ -0,0 +1,35 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { 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(), + validateRequest, + authController.login1 +); + +router.post( + '/login2', + authLimiter, + body('email').exists().trim().notEmpty(), + body('clientProof').exists().trim().notEmpty(), + validateRequest, + authController.login2 +); + +router.post( + '/mfa', + authLimiter, + body('email').exists().trim().notEmpty(), + body('mfaToken').exists().trim().notEmpty(), + validateRequest, + authController.verifyMfaToken +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 6f698e316..e51bb7588 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,3 +1,4 @@ +import auth from './auth'; import users from './users'; import organizations from './organizations'; import workspace from './workspace'; @@ -8,6 +9,7 @@ import apiKeyData from './apiKeyData'; import environment from "./environment" export { + auth, users, organizations, workspace, diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 48c015cca..e93f15ae8 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -1,8 +1,10 @@ import express from 'express'; const router = express.Router(); import { - requireAuth + requireAuth, + validateRequest } from '../../middleware'; +import { body, param } from 'express-validator'; import { usersController } from '../../controllers/v2'; router.get( @@ -13,6 +15,16 @@ router.get( usersController.getMe ); +router.patch( + '/me/mfa', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + body('isMfaEnabled').exists().isBoolean(), + validateRequest, + usersController.updateMyMfaEnabled +); + router.get( '/me/organizations', requireAuth({ diff --git a/backend/src/services/TokenService.ts b/backend/src/services/TokenService.ts new file mode 100644 index 000000000..6299f1d54 --- /dev/null +++ b/backend/src/services/TokenService.ts @@ -0,0 +1,69 @@ +import { Types } from 'mongoose'; +import { createTokenHelper, validateTokenHelper } from '../helpers/token'; + +/** + * Class to handle token actions + * TODO: elaborate more on this class + */ +class TokenService { + /** + * Create a token [token] for type [type] with associated details + * @param {Object} obj + * @param {String} obj.type - type or context of token (e.g. emailConfirmation) + * @param {String} obj.email - email associated with the token + * @param {String} obj.phoneNumber - phone number associated with the token + * @param {Types.ObjectId} obj.organizationId - id of organization associated with the token + * @returns {String} token - the token to create + */ + static async createToken({ + type, + email, + phoneNumber, + organizationId + }: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + }) { + return await createTokenHelper({ + type, + email, + phoneNumber, + organizationId + }); + } + + /** + * Validate whether or not token [token] and its associated details match a token in the DB + * @param {Object} obj + * @param {String} obj.type - type or context of token (e.g. emailConfirmation) + * @param {String} obj.email - email associated with the token + * @param {String} obj.phoneNumber - phone number associated with the token + * @param {Types.ObjectId} obj.organizationId - id of organization associated with the token + * @param {String} obj.token - the token to validate + */ + static async validateToken({ + type, + email, + phoneNumber, + organizationId, + token + }: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + token: string; + }) { + return await validateTokenHelper({ + type, + email, + phoneNumber, + organizationId, + token + }); + } +} + +export default TokenService; \ No newline at end of file diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index c53829922..8ac393cf5 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -3,11 +3,13 @@ import postHogClient from './PostHogClient'; import BotService from './BotService'; import EventService from './EventService'; import IntegrationService from './IntegrationService'; +import TokenService from './TokenService'; export { DatabaseService, postHogClient, BotService, EventService, - IntegrationService + IntegrationService, + TokenService } \ No newline at end of file diff --git a/backend/src/templates/emailMfa.handlebars b/backend/src/templates/emailMfa.handlebars new file mode 100644 index 000000000..489c9dd30 --- /dev/null +++ b/backend/src/templates/emailMfa.handlebars @@ -0,0 +1,19 @@ + + + + + + + MFA Code + + + +

Infisical

+

Sign in attempt requires further verification

+

Your MFA code is below — enter it where you started signing in to Infisical.

+

{{code}}

+

The MFA code will be valid for 2 minutes.

+

Not you? Contact Infisical or your administrator immediately.

+ + + \ No newline at end of file diff --git a/backend/src/templates/emailVerification.handlebars b/backend/src/templates/emailVerification.handlebars index f1fb56af7..14effa33e 100644 --- a/backend/src/templates/emailVerification.handlebars +++ b/backend/src/templates/emailVerification.handlebars @@ -1,15 +1,19 @@ + - Email Verification + +

Infisical

Confirm your email address

-

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical. +

{{code}}

Questions about setting up Infisical? Email us at support@infisical.com

+ \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index dc1ce6f78..031f78a2f 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -42,6 +42,15 @@ import { } from './action'; import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp'; import { PLAN_STARTER, PLAN_PRO } from './stripe'; +import { + MFA_METHOD_EMAIL +} from './user'; +import { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} from './token'; export { OWNER, @@ -84,4 +93,9 @@ export { SMTP_HOST_MAILGUN, PLAN_STARTER, PLAN_PRO, + MFA_METHOD_EMAIL, + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET }; diff --git a/backend/src/variables/token.ts b/backend/src/variables/token.ts new file mode 100644 index 000000000..ecb63990f --- /dev/null +++ b/backend/src/variables/token.ts @@ -0,0 +1,11 @@ +const TOKEN_EMAIL_CONFIRMATION = 'emailConfirmation'; +const TOKEN_EMAIL_MFA = 'emailMfa'; +const TOKEN_EMAIL_ORG_INVITATION = 'organizationInvitation'; +const TOKEN_EMAIL_PASSWORD_RESET = 'passwordReset'; + +export { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} \ No newline at end of file diff --git a/backend/src/variables/user.ts b/backend/src/variables/user.ts new file mode 100644 index 000000000..baa27d35d --- /dev/null +++ b/backend/src/variables/user.ts @@ -0,0 +1,5 @@ +const MFA_METHOD_EMAIL = 'email'; + +export { + MFA_METHOD_EMAIL +} \ No newline at end of file From cf5603c8e3cb6bfaa9104a74d2fe78622bf1a7dc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 30 Jan 2023 19:38:13 +0700 Subject: [PATCH 02/11] Finish preliminary backwards-compatible transition from user encryption scheme v1 to v2 with argon2 and protected key --- backend/src/app.ts | 2 + .../src/controllers/v1/passwordController.ts | 37 ++- .../src/controllers/v1/signupController.ts | 208 +-------------- backend/src/controllers/v2/authController.ts | 16 +- backend/src/controllers/v2/index.ts | 2 + .../src/controllers/v2/signupController.ts | 239 ++++++++++++++++++ backend/src/helpers/user.ts | 34 ++- backend/src/models/user.ts | 26 +- backend/src/routes/v1/password.ts | 46 ++-- backend/src/routes/v1/signup.ts | 37 +-- backend/src/routes/v2/index.ts | 2 + backend/src/routes/v2/signup.ts | 49 ++++ docs/security/data-model.mdx | 36 ++- docs/security/mechanics.mdx | 6 +- frontend/.eslintrc.js | 6 + frontend/next.config.js | 30 ++- frontend/package-lock.json | 33 +++ frontend/package.json | 3 + .../src/components/signup/UserInfoStep.tsx | 112 +++++--- .../src/components/utilities/attemptLogin.ts | 86 +++++-- .../utilities/cryptography/aes-256-gcm.ts | 4 +- .../utilities/cryptography/changePassword.ts | 100 +++++--- .../utilities/cryptography/crypto.ts | 54 +++- .../utilities/saveTokenToLocalStorage.ts | 41 ++- .../src/pages/api/auth/ChangePassword2.ts | 28 +- .../auth/CompleteAccountInformationSignup.ts | 40 ++- .../CompleteAccountInformationSignupInvite.ts | 31 ++- frontend/src/pages/api/auth/Login1.ts | 2 +- frontend/src/pages/api/auth/Login2.ts | 11 +- frontend/src/pages/api/auth/Logout.ts | 14 +- .../auth/resetPasswordOnAccountRecovery.ts | 29 ++- frontend/src/pages/password-reset.tsx | 67 ++++- frontend/src/pages/signupinvite.tsx | 105 +++++--- 33 files changed, 1058 insertions(+), 478 deletions(-) create mode 100644 backend/src/controllers/v2/signupController.ts create mode 100644 backend/src/routes/v2/signup.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 831a046ee..828822aea 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,6 +42,7 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { + signup as v2SignupRouter, auth as v2AuthRouter, users as v2UsersRouter, organizations as v2OrganizationsRouter, @@ -110,6 +111,7 @@ app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes +app.use('/api/v2/signup', v2SignupRouter); app.use('/api/v2/auth', v2AuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 6d10fcbbd..5c867a974 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -165,8 +165,18 @@ export const srp1 = async (req: Request, res: Response) => { */ export const changePassword = async (req: Request, res: Response) => { try { - const { clientProof, encryptedPrivateKey, iv, tag, salt, verifier } = - req.body; + const { + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + const user = await User.findOne({ email: req.user.email }).select('+salt +verifier'); @@ -192,9 +202,13 @@ export const changePassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, @@ -322,9 +336,12 @@ export const getBackupPrivateKey = async (req: Request, res: Response) => { export const resetPassword = async (req: Request, res: Response) => { try { const { + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, } = req.body; @@ -332,9 +349,13 @@ export const resetPassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 1d56ae0aa..961bb162c 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -1,16 +1,12 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; -import { User, MembershipOrg } from '../../models'; -import { completeAccount } from '../../helpers/user'; +import { User } from '../../models'; import { sendEmailVerification, checkEmailVerification, - initializeDefaultOrg } from '../../helpers/signup'; -import { issueTokens, createToken } from '../../helpers/auth'; -import { INVITED, ACCEPTED } from '../../variables'; -import axios from 'axios'; +import { createToken } from '../../helpers/auth'; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -102,202 +98,4 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { user, token }); -}; - -/** - * Complete setting up user by adding their personal and auth information as part of the - * signup flow - * @param req - * @param res - * @returns - */ -export const completeAccountSignup = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier, - organizationName - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null - - // initialize default organization and workspace - await initializeDefaultOrg({ - organizationName, - user - }); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - - // sending a welcome email to new users - if (process.env.LOOPS_API_KEY) { - await axios.post("https://app.loops.so/api/v1/events/send", { - "email": email, - "eventName": "Sign Up", - "firstName": firstName, - "lastName": lastName - }, { - headers: { - "Accept": "application/json", - "Authorization": "Bearer " + process.env.LOOPS_API_KEY - }, - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; -/** - * Complete setting up user by adding their personal and auth information as part of the - * invite flow - * @param req - * @param res - * @returns - */ -export const completeAccountInvite = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - const membershipOrg = await MembershipOrg.findOne({ - inviteEmail: email, - status: INVITED - }); - - if (!membershipOrg) throw new Error('Failed to find invitations for email'); - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; +}; \ No newline at end of file diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index c39939251..60bcef912 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -79,15 +79,11 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - - // check to see if user has MFA enabled; if yes then issue MFA-token - // TODO: may have to figure out a better token system for tokens with varying expirations - // (e.g. for org-invitations vs. auth etc.) try { const { email, clientProof } = req.body; const user = await User.findOne({ email - }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); if (!user) throw new Error('Failed to find user'); @@ -142,6 +138,10 @@ export const login2 = async (req: Request, res: Response) => { // return (access) token in response return res.status(200).send({ 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, @@ -182,7 +182,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { const user = await User.findOne({ email - }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); if (!user) throw new Error('Failed to find user'); @@ -200,6 +200,10 @@ export const verifyMfaToken = async (req: Request, res: Response) => { // case: user does not have MFA enabled // return (access) token in response return res.status(200).send({ + encryptionVersion: user.encryptionVersion, + protectedKey: user.protectedKey ?? null, + protectedKeyIV: user.protectedKeyIV ?? null, + protectedKeyTag: user.protectedKeyTag ?? null, token: tokens.token, publicKey: user.publicKey, encryptedPrivateKey: user.encryptedPrivateKey, diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 7c5eaf180..0f79dff87 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,4 +1,5 @@ import * as authController from './authController'; +import * as signupController from './signupController'; import * as usersController from './usersController'; import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; @@ -10,6 +11,7 @@ import * as environmentController from './environmentController'; export { authController, + signupController, usersController, organizationsController, workspaceController, diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts new file mode 100644 index 000000000..db9d8a21b --- /dev/null +++ b/backend/src/controllers/v2/signupController.ts @@ -0,0 +1,239 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { User, MembershipOrg } from '../../models'; +import { completeAccount } from '../../helpers/user'; +import { + initializeDefaultOrg +} from '../../helpers/signup'; +import { issueTokens } 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 + * @param req + * @param res + * @returns + */ +export const completeAccountSignup = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier, + organizationName + }: { + email: string; + firstName: string; + lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + organizationName: string; + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null + + // initialize default organization and workspace + await initializeDefaultOrg({ + organizationName, + user + }); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + refreshToken = tokens.refreshToken; + + // sending a welcome email to new users + if (process.env.LOOPS_API_KEY) { + await axios.post("https://app.loops.so/api/v1/events/send", { + "email": email, + "eventName": "Sign Up", + "firstName": firstName, + "lastName": lastName + }, { + headers: { + "Accept": "application/json", + "Authorization": "Bearer " + process.env.LOOPS_API_KEY + }, + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token, + refreshToken + }); +}; + +/** + * Complete setting up user by adding their personal and auth information as part of the + * invite flow + * @param req + * @param res + * @returns + */ +export const completeAccountInvite = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + const membershipOrg = await MembershipOrg.findOne({ + inviteEmail: email, + status: INVITED + }); + + if (!membershipOrg) throw new Error('Failed to find invitations for email'); + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + refreshToken = tokens.refreshToken; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token, + refreshToken + }); +}; \ No newline at end of file diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index a89ddc45c..ae0845bc6 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/node'; -import { User, IUser } from '../models'; +import { User } from '../models'; /** * Initialize a user under email [email] @@ -28,10 +28,14 @@ const setupAccount = async ({ email }: { email: string }) => { * @param {String} obj.userId - id of user to finish setting up * @param {String} obj.firstName - first name of user * @param {String} obj.lastName - last name of user + * @param {Number} obj.encryptionVersion - version of auth encryption scheme used + * @param {String} obj.protectedKey - protected key in encryption version 2 + * @param {String} obj.protectedKeyIV - IV of protected key in encryption version 2 + * @param {String} obj.protectedKeyTag - tag of protected key in encryption version 2 * @param {String} obj.publicKey - publickey of user * @param {String} obj.encryptedPrivateKey - (encrypted) private key of user - * @param {String} obj.iv - iv for (encrypted) private key of user - * @param {String} obj.tag - tag for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyIV - iv for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyTag - tag for (encrypted) private key of user * @param {String} obj.salt - salt for auth SRP * @param {String} obj.verifier - verifier for auth SRP * @returns {Object} user - the completed user @@ -40,20 +44,28 @@ const completeAccount = async ({ userId, firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }: { userId: string; firstName: string; lastName: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; }) => { @@ -67,10 +79,14 @@ const completeAccount = async ({ { firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index fbf3b2791..e709291cb 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,11 +1,14 @@ import { Schema, model, Types } from 'mongoose'; -import { MFA_METHOD_EMAIL } from '../variables'; export interface IUser { _id: Types.ObjectId; email: string; firstName?: string; lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey?: string; encryptedPrivateKey?: string; iv?: string; @@ -28,6 +31,23 @@ const userSchema = new Schema( lastName: { type: String }, + encryptionVersion: { + type: Number, + select: false, + default: 1 // to resolve backward-compatibility issues + }, + protectedKey: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyIV: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyTag: { // introduced as part of encryption version 2 + type: String, + select: false + }, publicKey: { type: String, select: false @@ -36,11 +56,11 @@ const userSchema = new Schema( type: String, select: false }, - iv: { + iv: { // iv of [encryptedPrivateKey] type: String, select: false }, - tag: { + tag: { // tag of [encryptedPrivateKey] type: String, select: false }, diff --git a/backend/src/routes/v1/password.ts b/backend/src/routes/v1/password.ts index 784ef1813..bc353cf08 100644 --- a/backend/src/routes/v1/password.ts +++ b/backend/src/routes/v1/password.ts @@ -10,7 +10,7 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientPublicKey').exists().trim().notEmpty(), + body('clientPublicKey').exists().isString().trim().notEmpty(), validateRequest, passwordController.srp1 ); @@ -22,11 +22,14 @@ router.post( acceptedAuthModes: ['jwt'] }), body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.changePassword ); @@ -34,7 +37,7 @@ router.post( router.post( '/email/password-reset', passwordLimiter, - body('email').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), validateRequest, passwordController.emailPasswordReset ); @@ -42,8 +45,8 @@ router.post( router.post( '/email/password-reset-verify', passwordLimiter, - body('email').exists().trim().notEmpty().isEmail(), - body('code').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), + body('code').exists().isString().trim().notEmpty(), validateRequest, passwordController.emailPasswordResetVerify ); @@ -61,12 +64,12 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), // (backup) private key encrypted under a strong key - body('iv').exists().trim().notEmpty(), // new iv for (backup) private key - body('tag').exists().trim().notEmpty(), // new tag for (backup) private key - body('salt').exists().trim().notEmpty(), // salt generated from strong key - body('verifier').exists().trim().notEmpty(), // salt generated from strong key + body('clientProof').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // (backup) private key encrypted under a strong key + body('iv').exists().isString().trim().notEmpty(), // new iv for (backup) private key + body('tag').exists().isString().trim().notEmpty(), // new tag for (backup) private key + body('salt').exists().isString().trim().notEmpty(), // salt generated from strong key + body('verifier').exists().isString().trim().notEmpty(), // salt generated from strong key validateRequest, passwordController.createBackupPrivateKey ); @@ -74,11 +77,14 @@ router.post( router.post( '/password-reset', requireSignupAuth, - body('encryptedPrivateKey').exists().trim().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.resetPassword ); diff --git a/backend/src/routes/v1/signup.ts b/backend/src/routes/v1/signup.ts index d1582474c..35a043c8e 100644 --- a/backend/src/routes/v1/signup.ts +++ b/backend/src/routes/v1/signup.ts @@ -1,7 +1,7 @@ import express from 'express'; const router = express.Router(); import { body } from 'express-validator'; -import { requireSignupAuth, validateRequest } from '../../middleware'; +import { validateRequest } from '../../middleware'; import { signupController } from '../../controllers/v1'; import { authLimiter } from '../../helpers/rateLimiter'; @@ -22,39 +22,4 @@ router.post( signupController.verifyEmailSignup ); -router.post( - '/complete-account/signup', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - body('organizationName').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountSignup -); - -router.post( - '/complete-account/invite', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountInvite -); - export default router; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index e51bb7588..cf0f9a22b 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,4 +1,5 @@ import auth from './auth'; +import signup from './signup'; import users from './users'; import organizations from './organizations'; import workspace from './workspace'; @@ -10,6 +11,7 @@ import environment from "./environment" export { auth, + signup, users, organizations, workspace, diff --git a/backend/src/routes/v2/signup.ts b/backend/src/routes/v2/signup.ts new file mode 100644 index 000000000..138591879 --- /dev/null +++ b/backend/src/routes/v2/signup.ts @@ -0,0 +1,49 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { requireSignupAuth, validateRequest } from '../../middleware'; +import { signupController } from '../../controllers/v2'; +import { authLimiter } from '../../helpers/rateLimiter'; + +router.post( + '/complete-account/signup', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + body('organizationName').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountSignup +); + +router.post( + '/complete-account/invite', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountInvite +); + +export default router; \ No newline at end of file diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx index 6c60f9e41..6c07cb479 100644 --- a/docs/security/data-model.mdx +++ b/docs/security/data-model.mdx @@ -6,22 +6,50 @@ Infisical stores a range of data namely user, secrets, keys, organization, proje ## Users -The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. +The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptionVersion`, `protectedKey`, `protectedKeyIV`, `protectedKeyTag`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. -Infisical makes a usability-security tradeoff to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. In any case, private keys are symmetrically encrypted locally by user passwords which are not sent to the server — this is done with SRP. +Infisical makes a usability-security tradeoff that is to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. + + + `encryptedPrivateKey` is obtained by symmetrically encrypting the user's + private key locally with a protected key which is encrypted by the key derived + from the user's password and salt. Encryption is done via `AES256-GCM` and key + derivation via `argon2id`. The user's password is not sent to the server — + this is done with SRP. + ## Secrets -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretKeyHash`, `secretValueCiphertext`, `secretValueIV`, `secretValueTag`, and `secretValueHash`. +The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. Each secret is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. -## Keys +## Project Keys The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. Infisical stores copies of project keys, one for each member of a project, encrypted under each member's public key. +## Bots + +The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. + +Each project comes with a bot that has its own public-private key pair; its private key is encrypted by the server's symmetric key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. + + + Sharing secrets with Infisical so they can be synced to integrations like + Vercel, GitHub, and Netlify is something we make sure users consent to before + opting in. + + ## Organizations and Workspaces The `Organization`, `Workspace`, `MembershipOrg`, and `Membership` models contain enrollment information for organizations and projects; they are used to check if users are authorized to retrieve select secrets. + +## Service Tokens + +The `ServiceTokenData` model contains data for service tokens that enable users to fetch secrets from a particular project and environment; each service token data record includes an (encrypted) copy of the project key that it is bound to as well as a validation hash for `bcrypt`. + +## API Keys + +The `APIKeyData` model contains data for API keys that enable users to interact with [Infisical's Open API](https://infisical.com/docs/api-reference/overview/introduction); each API key data record includes a validation hash for `bcrypt`. diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index 814524d73..2be11c6fc 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -4,7 +4,11 @@ title: "Mechanics" ## Signup -During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally (private keys are symmetrically encrypted by the user's newly-made password), and forwards SRP-related values and user identifier information to the server. This includes `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `organizationName`. +During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally, generates a user salt, generates a 256-bit key, and enters their password. + +The 256-bit key is used to encrypt the private key; the 256-bit key itself is then encrypted by a key generated from the user's password and salt with key derivation function `argon2id`. The resulting, 256-bit key the protected key. + +The encrypted private key, protected key, user identifier information, and SRP details are forwarded to the server. Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory under a write-only class `SecurityClient` that appends the token to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 7c471fdc6..155e6d6e8 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,4 +1,9 @@ module.exports = { + overrides: [ + { + files: ["next.config.js"] + } + ], root: true, env: { browser: true, @@ -87,6 +92,7 @@ module.exports = { } ] }, + ignorePatterns: ['next.config.js'], settings: { 'import/resolver': { typescript: { diff --git a/frontend/next.config.js b/frontend/next.config.js index 06c56dd78..5e7c81270 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,9 +1,11 @@ + // @ts-check /** * @type {import('next').NextConfig} **/ const { i18n } = require("./next-i18next.config.js"); +const path = require('path'); const ContentSecurityPolicy = ` default-src 'self'; @@ -65,7 +67,33 @@ module.exports = { }, ]; }, - webpack: (config, { isServer, webpack }) => { + webpack: (config, { isServer, webpack }) => { // config + config.module.rules.push({ + test: /\.wasm$/, + loader: "base64-loader", + type: "javascript/auto", + }); + + config.module.noParse = /\.wasm$/; + + config.module.rules.forEach((rule) => { + (rule.oneOf || []).forEach((oneOf) => { + if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) { + oneOf.exclude.push(/\.wasm$/); + } + }); + }); + + if (!isServer) { + config.resolve.fallback.fs = false; + } + + // Perform customizations to webpack config + config.plugins.push( + new webpack.IgnorePlugin({ resourceRegExp: /\/__tests__\// }) + ); + + // Important: return the modified config return config; }, i18n, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44d791bb0..6a6a4e6c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,9 +25,12 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@types/argon2-browser": "^1.18.1", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "fs": "^0.0.1-security", @@ -6618,6 +6621,11 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "node_modules/@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -8027,6 +8035,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "node_modules/argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -8621,6 +8634,11 @@ } ] }, + "node_modules/base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "node_modules/better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", @@ -26728,6 +26746,11 @@ "@babel/runtime": "^7.12.5" } }, + "@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -27860,6 +27883,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -28295,6 +28323,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c30f749e..25efc0ccb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,9 +32,12 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@types/argon2-browser": "^1.18.1", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "fs": "^0.0.1-security", diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index bf9fa1edb..1a43d2dc4 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import React, { useState } from 'react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; @@ -14,6 +16,8 @@ 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'; // eslint-disable-next-line new-cap const client = new jsrp.client(); @@ -94,17 +98,9 @@ export default function UserInfoStep({ const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const PRIVATE_KEY = encodeBase64(secretKeyUint8Array); - const PUBLIC_KEY = encodeBase64(publicKeyUint8Array); - - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: PRIVATE_KEY, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }) as { ciphertext: string; iv: string; tag: string }; - - localStorage.setItem('PRIVATE_KEY', PRIVATE_KEY); + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); + localStorage.setItem('PRIVATE_KEY', privateKey); client.init( { @@ -113,35 +109,81 @@ export default function UserInfoStep({ }, async () => { client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => { - const response = await completeAccountInformationSignup({ - email, - firstName, - lastName, - organizationName: `${firstName}'s organization`, - publicKey: PUBLIC_KEY, - ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - token: verificationToken - }); + try { + const derivedKey = await deriveArgonKey({ + password, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); - // if everything works, go the main dashboard page. - if (response.status === 200) { - // response = await response.json(); + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + const response = await completeAccountInformationSignup({ + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + 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(); - localStorage.setItem('publicKey', PUBLIC_KEY); - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + privateKey + }); - try { await attemptLogin(email, password, () => {}, router, true, false); incrementStep(); - } catch (error) { - setIsLoading(false); } + + } catch (error) { + setIsLoading(false); + console.error(error); } }); } @@ -258,4 +300,4 @@ export default function UserInfoStep({ ); -} +} \ No newline at end of file diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index acc2b67fe..f7b69751f 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -13,7 +13,7 @@ import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserP import getUser from '@app/pages/api/user/getUser'; import uploadKeys from '@app/pages/api/workspace/uploadKeys'; -import { encryptAssymmetric } from './cryptography/crypto'; +import { deriveArgonKey, encryptAssymmetric } from './cryptography/crypto'; import encryptSecrets from './secrets/encryptSecrets'; import Telemetry from './telemetry/Telemetry'; import { saveTokenToLocalStorage } from './saveTokenToLocalStorage'; @@ -59,29 +59,81 @@ const attemptLogin = async ( const clientProof = client.getProof(); // called M1 // if everything works, go the main dashboard page. - const { token, publicKey, encryptedPrivateKey, iv, tag } = await login2( + const { // mfaEnabled + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, + token, + publicKey, + encryptedPrivateKey, + iv, + tag + } = await login2( email, clientProof ); SecurityClient.setToken(token); - const privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }); + 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') + }); - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); + saveTokenToLocalStorage({ + publicKey, + 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') + }); + + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv, + tag, + privateKey + }); + } + + if (!privateKey) throw new Error('Failed to decrypt private key'); const userOrgs = await getOrganizations(); const userOrgsData = userOrgs.map((org: { _id: string }) => org._id); diff --git a/frontend/src/components/utilities/cryptography/aes-256-gcm.ts b/frontend/src/components/utilities/cryptography/aes-256-gcm.ts index 5a2301c25..72ac5a6b2 100644 --- a/frontend/src/components/utilities/cryptography/aes-256-gcm.ts +++ b/frontend/src/components/utilities/cryptography/aes-256-gcm.ts @@ -9,14 +9,14 @@ const BLOCK_SIZE_BYTES = 16; // 128 bit interface EncryptProps { text: string; - secret: string; + secret: string | Buffer; } interface DecryptProps { ciphertext: string; iv: string; tag: string; - secret: string; + secret: string | Buffer; } interface EncryptOutputProps { diff --git a/frontend/src/components/utilities/cryptography/changePassword.ts b/frontend/src/components/utilities/cryptography/changePassword.ts index db72856ed..bdc741b3d 100644 --- a/frontend/src/components/utilities/cryptography/changePassword.ts +++ b/frontend/src/components/utilities/cryptography/changePassword.ts @@ -1,14 +1,20 @@ /* eslint-disable new-cap */ +import crypto from 'crypto'; + import jsrp from 'jsrp'; import changePassword2 from '@app/pages/api/auth/ChangePassword2'; import SRP1 from '@app/pages/api/auth/SRP1'; +import { saveTokenToLocalStorage } from '../saveTokenToLocalStorage'; import Aes256Gcm from './aes-256-gcm'; +import { deriveArgonKey } from './crypto'; const clientOldPassword = new jsrp.client(); const clientNewPassword = new jsrp.client(); +// TODO: modify this function + /** * This function loggs in the user (whether it's right after signup, or a normal login) * @param {*} email @@ -63,43 +69,75 @@ const changePassword = async ( }, async () => { clientNewPassword.createVerifier(async (err, result) => { - // The Blob part here is needed to account for symbols that count as 2+ bytes (e.g., é, å, ø) - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + + const derivedKey = await deriveArgonKey({ + password: newPassword, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ text: localStorage.getItem('PRIVATE_KEY') as string, - secret: newPassword - .slice(0, 32) - .padStart( - 32 + (newPassword.slice(0, 32).length - new Blob([newPassword]).size), - '0' - ) + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) }); - if (ciphertext) { - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + let res; + try { + res = await changePassword2({ + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier + }); + + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + }); - let res; - try { - res = await changePassword2({ - encryptedPrivateKey: ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - clientProof - }); - if (res && res.status === 400) { - setCurrentPasswordError(true); - } else if (res && res.status === 200) { - setPasswordChanged(true); - setCurrentPassword(''); - setNewPassword(''); - } - } catch (error) { + if (res && res.status === 400) { setCurrentPasswordError(true); - console.log(error); + } else if (res && res.status === 200) { + setPasswordChanged(true); + setCurrentPassword(''); + setNewPassword(''); } + } catch (error) { + setCurrentPasswordError(true); + console.log(error); } }); } diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index d1db995ee..961dee83f 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -1,3 +1,5 @@ +import argon2 from 'argon2-browser'; + import aes from './aes-256-gcm'; const nacl = require('tweetnacl'); @@ -9,6 +11,50 @@ type EncryptAsymmetricProps = { privateKey: string; }; +/** + * Derive a key from password [password] and salt [salt] using Argon2id + * @param {Object} obj + * @param {String} obj.password - password to derive key from + * @param {String} obj.salt - salt to derive key from + * @param {Number} obj.mem - used memory, in KiB + * @param {Number} obj.time - number of iterations + * @param {Number} obj.parallelism - desired parallelism + * @param {Number} obj.hashLen - desired hash length (i.e. byte-length of derived key) + * @returns + */ +const deriveArgonKey = async ({ + password, + salt, + mem, + time, + parallelism, + hashLen +}: { + password: string; + salt: string; + mem: number; + time: number; + parallelism: number; + hashLen: number; +}) => { + let derivedKey; + try { + derivedKey = await argon2.hash({ + pass: password, + salt, + type: argon2.ArgonType.Argon2id, + mem, + time, + parallelism, + hashLen + }); + } catch (err) { + console.error(err); + } + + return derivedKey; +} + /** * Return assymmetrically encrypted [plaintext] using [publicKey] where * [publicKey] likely belongs to the recipient. @@ -138,4 +184,10 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps): return plaintext; }; -export { decryptAssymmetric, decryptSymmetric, encryptAssymmetric, encryptSymmetric }; +export { + decryptAssymmetric, + decryptSymmetric, + deriveArgonKey, + encryptAssymmetric, + encryptSymmetric +}; diff --git a/frontend/src/components/utilities/saveTokenToLocalStorage.ts b/frontend/src/components/utilities/saveTokenToLocalStorage.ts index b624babd6..90cf5bd7d 100644 --- a/frontend/src/components/utilities/saveTokenToLocalStorage.ts +++ b/frontend/src/components/utilities/saveTokenToLocalStorage.ts @@ -1,12 +1,18 @@ interface Props { - publicKey: string; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + publicKey?: string; encryptedPrivateKey: string; iv: string; tag: string; - privateKey: string; + privateKey?: string; } export const saveTokenToLocalStorage = ({ + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, iv, @@ -14,11 +20,38 @@ export const saveTokenToLocalStorage = ({ privateKey, }: Props) => { try { - localStorage.setItem("publicKey", publicKey); + localStorage.removeItem("protectedKey"); + localStorage.removeItem("protectedKeyIV"); + localStorage.removeItem("protectedKeyTag"); + localStorage.removeItem("publicKey"); + localStorage.removeItem("encryptedPrivateKey"); + localStorage.removeItem("iv"); + localStorage.removeItem("tag"); + localStorage.removeItem("PRIVATE_KEY"); + + if (protectedKey) { + localStorage.setItem("protectedKey", protectedKey); + } + + if (protectedKeyIV) { + localStorage.setItem("protectedKeyIV", protectedKeyIV); + } + + if (protectedKeyTag) { + localStorage.setItem("protectedKeyTag", protectedKeyTag); + } + + if (publicKey) { + localStorage.setItem("publicKey", publicKey); + } + + if (privateKey) { + localStorage.setItem("PRIVATE_KEY", privateKey); + } + localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey); localStorage.setItem("iv", iv); localStorage.setItem("tag", tag); - localStorage.setItem("PRIVATE_KEY", privateKey); } catch (err) { if (err instanceof Error) { throw new Error( diff --git a/frontend/src/pages/api/auth/ChangePassword2.ts b/frontend/src/pages/api/auth/ChangePassword2.ts index 71857f8b7..b2ab4906e 100644 --- a/frontend/src/pages/api/auth/ChangePassword2.ts +++ b/frontend/src/pages/api/auth/ChangePassword2.ts @@ -1,12 +1,15 @@ import SecurityClient from '@app/components/utilities/SecurityClient'; interface Props { + clientProof: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; - clientProof: string; } /** @@ -14,7 +17,17 @@ interface Props { * @param {*} clientPublicKey * @returns */ -const changePassword2 = ({ encryptedPrivateKey, iv, tag, salt, verifier, clientProof }: Props) => +const changePassword2 = ({ + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier +}: Props) => SecurityClient.fetchCall('/api/v1/password/change-password', { method: 'POST', headers: { @@ -22,9 +35,12 @@ const changePassword2 = ({ encryptedPrivateKey, iv, tag, salt, verifier, clientP }, body: JSON.stringify({ clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts index 3fe985cab..3236bdabd 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts @@ -2,11 +2,14 @@ interface Props { email: string; firstName: string; lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; - ciphertext: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; organizationName: string; - iv: string; - tag: string; salt: string; verifier: string; token: string; @@ -19,6 +22,9 @@ interface Props { * @param {string} obj.email - email of the user completing signup * @param {string} obj.firstName - first name of the user completing signup * @param {string} obj.lastName - last name of the user completing sign up + * @param {string} obj.protectedKey - protected key in encryption version 2 + * @param {string} obj.protectedKeyIV - IV of protected key in encryption version 2 + * @param {string} obj.protectedKeyTag - tag of protected key in encryption version 2 * @param {string} obj.organizationName - organization name for this user (usually, [FIRST_NAME]'s organization) * @param {string} obj.publicKey - public key of the user completing signup * @param {string} obj.ciphertext @@ -33,15 +39,18 @@ const completeAccountInformationSignup = ({ email, firstName, lastName, - organizationName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, - token -}: Props) => fetch('/api/v1/signup/complete-account/signup', { + token, + organizationName +}: Props) => fetch('/api/v2/signup/complete-account/signup', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -51,13 +60,16 @@ const completeAccountInformationSignup = ({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - encryptedPrivateKey: ciphertext, - organizationName, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, - verifier + verifier, + organizationName }) }); diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts index 6fada48ba..62a5b503f 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts @@ -2,10 +2,13 @@ interface Props { email: string; firstName: string; lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; - ciphertext: string; - iv: string; - tag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; token: string; @@ -31,27 +34,33 @@ const completeAccountInformationSignupInvite = ({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, token -}: Props) => fetch('/api/v1/signup/complete-account/invite', { +}: Props) => fetch('/api/v2/signup/complete-account/invite', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${ token}` + Authorization: `Bearer ${token}` }, body: JSON.stringify({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - encryptedPrivateKey: ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/api/auth/Login1.ts b/frontend/src/pages/api/auth/Login1.ts index 94a56866d..30f2656f7 100644 --- a/frontend/src/pages/api/auth/Login1.ts +++ b/frontend/src/pages/api/auth/Login1.ts @@ -10,7 +10,7 @@ interface Login1 { * @returns */ const login1 = async (email: string, clientPublicKey: string) => { - const response = await fetch("/api/v1/auth/login1", { + const response = await fetch("/api/v2/auth/login1", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/pages/api/auth/Login2.ts b/frontend/src/pages/api/auth/Login2.ts index 6c431cbb8..7443830f8 100644 --- a/frontend/src/pages/api/auth/Login2.ts +++ b/frontend/src/pages/api/auth/Login2.ts @@ -1,9 +1,14 @@ interface Login2Response { + mfaEnabled: boolean; + encryptionVersion: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey: string; encryptedPrivateKey: string; iv: string; - publicKey: string; tag: string; - token: string; } /** @@ -13,7 +18,7 @@ interface Login2Response { * @returns */ const login2 = async (email: string, clientProof: string) => { - const response = await fetch('/api/v1/auth/login2', { + const response = await fetch('/api/v2/auth/login2', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/frontend/src/pages/api/auth/Logout.ts b/frontend/src/pages/api/auth/Logout.ts index acf04bc1a..71f81b1bd 100644 --- a/frontend/src/pages/api/auth/Logout.ts +++ b/frontend/src/pages/api/auth/Logout.ts @@ -15,11 +15,15 @@ const logout = async () => if (res?.status === 200) { SecurityClient.setToken(''); // Delete the cookie by not setting a value; Alternatively clear the local storage - localStorage.setItem('publicKey', ''); - localStorage.setItem('encryptedPrivateKey', ''); - localStorage.setItem('iv', ''); - localStorage.setItem('tag', ''); - localStorage.setItem('PRIVATE_KEY', ''); + localStorage.removeItem('protectedKey'); + localStorage.removeItem('protectedKeyIV'); + localStorage.removeItem('protectedKeyTag'); + localStorage.removeItem('publicKey'); + localStorage.removeItem('encryptedPrivateKey'); + localStorage.removeItem('iv'); + localStorage.removeItem('tag'); + localStorage.removeItem('PRIVATE_KEY'); + console.log('User logged out', res); return res; } diff --git a/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts b/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts index 77c1a2aa0..2687a372a 100644 --- a/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts +++ b/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts @@ -1,10 +1,13 @@ interface Props { - verificationToken: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; + verificationToken: string; } /** @@ -19,22 +22,28 @@ interface Props { * @returns */ const resetPasswordOnAccountRecovery = ({ - verificationToken, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, - verifier + verifier, + verificationToken, }: Props) => fetch('/api/v1/password/password-reset', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${ verificationToken}` + Authorization: `Bearer ${verificationToken}` }, body: JSON.stringify({ + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/password-reset.tsx b/frontend/src/pages/password-reset.tsx index 7da1778ea..4c9020a68 100644 --- a/frontend/src/pages/password-reset.tsx +++ b/frontend/src/pages/password-reset.tsx @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import { useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; @@ -12,6 +14,7 @@ import passwordCheck from '@app/components/utilities/checks/PasswordCheck'; import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; +import { deriveArgonKey } from '../components/utilities/cryptography/crypto'; import EmailVerifyOnPasswordReset from './api/auth/EmailVerifyOnPasswordReset'; import getBackupEncryptedPrivateKey from './api/auth/getBackupEncryptedPrivateKey'; import resetPasswordOnAccountRecovery from './api/auth/resetPasswordOnAccountRecovery'; @@ -39,6 +42,7 @@ export default function PasswordReset() { const getEncryptedKeyHandler = async () => { try { const result = await getBackupEncryptedPrivateKey({ verificationToken }); + setPrivateKey( Aes256Gcm.decrypt({ ciphertext: result.encryptedPrivateKey, @@ -64,13 +68,12 @@ export default function PasswordReset() { }); 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 }; + // 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( { @@ -79,13 +82,51 @@ export default function PasswordReset() { }, async () => { client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => { - const response = await resetPasswordOnAccountRecovery({ - verificationToken, - encryptedPrivateKey: ciphertext, - iv, - tag, + const derivedKey = await deriveArgonKey({ + password: newPassword, salt: result.salt, - verifier: result.verifier + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + const response = await resetPasswordOnAccountRecovery({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + verificationToken }); // if everything works, go the main dashboard page. diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index ce8db9afe..6077b017e 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -1,5 +1,7 @@ /* eslint-disable no-nested-ternary */ /* eslint-disable @typescript-eslint/no-unused-vars */ +import crypto from 'crypto'; + import { useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; @@ -17,6 +19,7 @@ import InputField from '@app/components/basic/InputField'; import attemptLogin from '@app/components/utilities/attemptLogin'; import passwordCheck from '@app/components/utilities/checks/PasswordCheck'; import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; +import { deriveArgonKey } from '@app/components/utilities/cryptography/crypto'; import issueBackupKey from '@app/components/utilities/cryptography/issueBackupKey'; import completeAccountInformationSignupInvite from './api/auth/CompleteAccountInformationSignupInvite'; @@ -75,17 +78,17 @@ export default function SignupInvite() { const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const PRIVATE_KEY = encodeBase64(secretKeyUint8Array); - const PUBLIC_KEY = encodeBase64(publicKeyUint8Array); + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: PRIVATE_KEY, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }); + // const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + // text: PRIVATE_KEY, + // secret: password + // .slice(0, 32) + // .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') + // }); - localStorage.setItem('PRIVATE_KEY', PRIVATE_KEY); + localStorage.setItem('PRIVATE_KEY', privateKey); client.init( { @@ -94,35 +97,73 @@ export default function SignupInvite() { }, async () => { client.createVerifier(async (err, result) => { - let response = await completeAccountInformationSignupInvite({ - email, - firstName, - lastName, - publicKey: PUBLIC_KEY, - ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - token: verificationToken - }); + try { + const derivedKey = await deriveArgonKey({ + password, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); - // if everything works, go the main dashboard page. - if (!errorCheck && response.status === 200) { - response = await response.json(); + if (!derivedKey) throw new Error('Failed to derive key from password'); - localStorage.setItem('publicKey', PUBLIC_KEY); - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + let response = await completeAccountInformationSignupInvite({ + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + token: verificationToken + }); + + // if everything works, go the main dashboard page. + if (!errorCheck && response.status === 200) { + response = await response.json(); + + localStorage.setItem('publicKey', publicKey); + localStorage.setItem('encryptedPrivateKey', encryptedPrivateKey); + localStorage.setItem('iv', encryptedPrivateKeyIV); + localStorage.setItem('tag', encryptedPrivateKeyTag); - try { await attemptLogin(email, password, setErrorLogin, router, false, false); setStep(3); - } catch (error) { - setIsLoading(false); - console.log('Error', error); } + } catch (error) { + setIsLoading(false); + console.error(error); } }); } From 669861d7a87cfc768e383734c06f5715351966b5 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 9 Feb 2023 15:49:47 -0800 Subject: [PATCH 03/11] General frontend structure for 2FA - done --- frontend/src/components/login/2FAStep.tsx | 136 ++++++++++++++++++ .../signup/DonwloadBackupPDFStep.tsx | 4 +- .../src/components/signup/UserInfoStep.tsx | 2 +- frontend/src/pages/login.tsx | 3 + frontend/src/pages/settings/personal/[id].tsx | 5 + .../SecuritySection/SecuritySection.tsx | 31 ++++ .../SecuritySection/index.tsx | 1 + .../ProjectSettingsPage.tsx | 8 +- .../AutoCapitalizationSection.tsx | 2 +- 9 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/login/2FAStep.tsx create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/index.tsx diff --git a/frontend/src/components/login/2FAStep.tsx b/frontend/src/components/login/2FAStep.tsx new file mode 100644 index 000000000..dfa9b6645 --- /dev/null +++ b/frontend/src/components/login/2FAStep.tsx @@ -0,0 +1,136 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { useState } from 'react'; +import ReactCodeInput from 'react-code-input'; +import { useTranslation } from 'next-i18next'; + +import sendVerificationEmail from '@app/pages/api/auth/SendVerificationEmail'; + +import Button from '../basic/buttons/Button'; +import Error from '../basic/Error'; + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: 'monospace', + margin: '4px', + MozAppearance: 'textfield', + width: '55px', + borderRadius: '5px', + fontSize: '24px', + height: '55px', + paddingLeft: '7', + backgroundColor: '#0d1117', + color: 'white', + border: '1px solid #2d2f33', + textAlign: 'center', + outlineColor: '#8ca542', + 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 + * @returns + */ +export default function TwoFAStep({ + email, + incrementStep, + setCode, + codeError +}: CodeInputStepProps): JSX.Element { + const [isLoading, setIsLoading] = useState(false); + const [isResendingVerificationEmail, setIsResendingVerificationEmail] = useState(false); + const { t } = useTranslation(); + + const resendVerificationEmail = async () => { + setIsResendingVerificationEmail(true); + setIsLoading(true); + sendVerificationEmail(email); + setTimeout(() => { + setIsLoading(false); + setIsResendingVerificationEmail(false); + }, 2000); + }; + + return ( +
+

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

+

{email}

+
+ +
+
+ +
+ {codeError && } +
+
+
+
+ {t('signup:step2-resend-alert')} + + + +
+

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

+
+
+ ); +} diff --git a/frontend/src/components/signup/DonwloadBackupPDFStep.tsx b/frontend/src/components/signup/DonwloadBackupPDFStep.tsx index 664420c3b..240d6468a 100644 --- a/frontend/src/components/signup/DonwloadBackupPDFStep.tsx +++ b/frontend/src/components/signup/DonwloadBackupPDFStep.tsx @@ -31,7 +31,7 @@ export default function DonwloadBackupPDFStep({ return (
-

+

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

@@ -42,7 +42,7 @@ export default function DonwloadBackupPDFStep({ {t('signup:step4-description3')}
-
+
+ +
+
+ {!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. From 13b1805d0423c4fba16148818839931ee98ce3c8 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 13 Feb 2023 17:11:31 +0700 Subject: [PATCH 05/11] Checkpoint 2 --- frontend/src/components/utilities/SecurityClient.ts | 6 +++++- frontend/src/config/request.ts | 3 +++ frontend/src/reactQuery.ts | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/utilities/SecurityClient.ts b/frontend/src/components/utilities/SecurityClient.ts index fa1ec0bb0..f1ba05ed9 100644 --- a/frontend/src/components/utilities/SecurityClient.ts +++ b/frontend/src/components/utilities/SecurityClient.ts @@ -1,7 +1,11 @@ -import { getAuthToken, setAuthToken } from '@app/reactQuery'; +import { getAuthToken, setAuthToken , setMfaTempToken } from '@app/reactQuery'; // depreciated: go for apiRequest module in config/api export default class SecurityClient { + static setMfaToken(tokenStr: string) { + setMfaTempToken(tokenStr); + } + static setToken(tokenStr: string) { setAuthToken(tokenStr); } diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index 0e04789b2..7234da684 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -11,6 +11,9 @@ export const apiRequest = axios.create({ apiRequest.interceptors.request.use((config) => { const token = getAuthToken(); + console.log('interceptors'); + console.log('token', token); + console.log('config.headers', config.headers); if (token && config.headers) { // eslint-disable-next-line no-param-reassign config.headers.Authorization = `Bearer ${token}`; diff --git a/frontend/src/reactQuery.ts b/frontend/src/reactQuery.ts index c28dc39b1..7bb83b111 100644 --- a/frontend/src/reactQuery.ts +++ b/frontend/src/reactQuery.ts @@ -1,6 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; // this is saved in react-query cache +export const MFA_TEMP_TOKEN_CACHE_KEY = ['infisical__mfa-temp-token']; export const AUTH_TOKEN_CACHE_KEY = ['infisical__auth-token']; export const queryClient = new QueryClient({ @@ -13,9 +14,13 @@ export const queryClient = new QueryClient({ }); // set token in memory cache +export const setMfaTempToken = (token: string) => + queryClient.setQueryData(MFA_TEMP_TOKEN_CACHE_KEY, token); + export const setAuthToken = (token: string) => queryClient.setQueryData(AUTH_TOKEN_CACHE_KEY, token); +export const getMfaTempToken = () => queryClient.getQueryData(MFA_TEMP_TOKEN_CACHE_KEY) as string; export const getAuthToken = () => queryClient.getQueryData(AUTH_TOKEN_CACHE_KEY) as string; export const isLoggedIn = () => Boolean(getAuthToken()); From e1ad8fbee8a3aca5325bc20a6f83224e0816260e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 14 Feb 2023 17:38:58 +0700 Subject: [PATCH 06/11] Refactoring functions into services, helper functions, hooks, patch bugs --- backend/src/controllers/v2/authController.ts | 39 +++-- .../src/controllers/v2/signupController.ts | 27 +++- backend/src/helpers/token.ts | 95 +++++++----- backend/src/middleware/requestErrorHandler.ts | 7 +- docs/integrations/cicd/circleci.mdx | 34 ++++- docs/integrations/overview.mdx | 4 +- frontend/public/locales/en/signup.json | 4 +- frontend/public/locales/fr/signup.json | 2 +- frontend/public/locales/tr/signup.json | 2 +- frontend/src/components/login/MFAStep.tsx | 9 +- .../components/navigation/NavBarDashboard.tsx | 1 - .../src/components/signup/UserInfoStep.tsx | 14 +- .../components/utilities/SecurityClient.ts | 10 +- .../src/components/utilities/attemptLogin.ts | 136 +----------------- .../components/utilities/attemptLoginMfa.ts | 17 ++- frontend/src/config/request.ts | 19 ++- frontend/src/hooks/api/auth/index.tsx | 5 +- frontend/src/hooks/api/auth/queries.tsx | 29 +++- frontend/src/hooks/api/auth/types.ts | 21 +++ frontend/src/layouts/AppLayout/AppLayout.tsx | 1 + .../AppLayout/components/NavBar/NavBar.tsx | 12 +- .../auth/CompleteAccountInformationSignup.ts | 49 +++---- .../CompleteAccountInformationSignupInvite.ts | 49 +++---- frontend/src/pages/api/auth/Logout.ts | 31 ++-- frontend/src/pages/api/auth/resendMfaToken.ts | 29 ---- frontend/src/pages/api/auth/verifyMfaToken.ts | 28 ++-- frontend/src/pages/login.tsx | 8 +- frontend/src/pages/signup.tsx | 3 +- frontend/src/pages/signupinvite.tsx | 53 +++---- frontend/src/reactQuery.ts | 5 + 30 files changed, 383 insertions(+), 360 deletions(-) delete mode 100644 frontend/src/pages/api/auth/resendMfaToken.ts diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 9db83878b..1e9a73da0 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -269,19 +269,36 @@ export const verifyMfaToken = async (req: Request, res: Response) => { secure: NODE_ENV === 'production' ? true : false }); + interface VerifyMfaTokenRes { + encryptionVersion: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; + } + + const resObj: VerifyMfaTokenRes = { + encryptionVersion: user.encryptionVersion, + token: tokens.token, + publicKey: user.publicKey as string, + encryptedPrivateKey: user.encryptedPrivateKey as string, + iv: user.iv as string, + tag: user.tag as string + } + + if (user?.protectedKey && user?.protectedKeyIV && user?.protectedKeyTag) { + resObj.protectedKey = user.protectedKey; + resObj.protectedKeyIV = user.protectedKeyIV; + resObj.protectedKeyTag = user.protectedKeyTag; + } + // case: user does not have MFA enabled // return (access) token in response - return res.status(200).send({ - 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 - }); + return res.status(200).send(resObj); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts index 8a26a9e9a..0fb349edc 100644 --- a/backend/src/controllers/v2/signupController.ts +++ b/backend/src/controllers/v2/signupController.ts @@ -7,6 +7,7 @@ import { } from '../../helpers/signup'; import { issueAuthTokens } from '../../helpers/auth'; import { INVITED, ACCEPTED } from '../../variables'; +import { NODE_ENV } from '../../config'; import axios from 'axios'; /** @@ -105,7 +106,6 @@ export const completeAccountSignup = async (req: Request, res: Response) => { }); token = tokens.token; - refreshToken = tokens.refreshToken; // sending a welcome email to new users if (process.env.LOOPS_API_KEY) { @@ -121,6 +121,14 @@ export const completeAccountSignup = async (req: Request, res: Response) => { }, }); } + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -132,8 +140,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { return res.status(200).send({ message: 'Successfully set up account', user, - token, - refreshToken + token }); }; @@ -219,7 +226,14 @@ export const completeAccountInvite = async (req: Request, res: Response) => { }); token = tokens.token; - refreshToken = tokens.refreshToken; + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -227,11 +241,10 @@ export const completeAccountInvite = async (req: Request, res: Response) => { message: 'Failed to complete account setup' }); } - + return res.status(200).send({ message: 'Successfully set up account', user, - token, - refreshToken + token }); }; \ No newline at end of file diff --git a/backend/src/helpers/token.ts b/backend/src/helpers/token.ts index 551e4eb44..026bb4ae7 100644 --- a/backend/src/helpers/token.ts +++ b/backend/src/helpers/token.ts @@ -12,7 +12,7 @@ import { import { SALT_ROUNDS } from '../config'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { ForbiddenRequestError } from '../utils/errors'; /** * Create and store a token in the database for purpose [type] @@ -65,29 +65,45 @@ const createTokenHelper = async ({ break; } - interface Query { + interface TokenDataQuery { type: string; email?: string; phoneNumber?: string; organization?: Types.ObjectId; } + + interface TokenDataUpdate { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + tokenHash: string; + expiresAt: Date; + } - const query: Query = { type }; + const query: TokenDataQuery = { type }; + const update: TokenDataUpdate = { + type, + tokenHash: await bcrypt.hash(token, SALT_ROUNDS), + expiresAt + } - if (email) { query.email = email; } - if (phoneNumber) { query.phoneNumber = phoneNumber; } - if (organizationId) { query.organization = organizationId } + if (email) { + query.email = email; + update.email = email; + } + if (phoneNumber) { + query.phoneNumber = phoneNumber; + update.phoneNumber = phoneNumber; + } + if (organizationId) { + query.organization = organizationId + update.organization = organizationId + } await TokenData.findOneAndUpdate( query, - { - type, - email, - phoneNumber, - organization: organizationId, - tokenHash: await bcrypt.hash(token, SALT_ROUNDS), - expiresAt - }, + update, { new: true, upsert: true @@ -123,37 +139,38 @@ const validateTokenHelper = async ({ organizationId?: Types.ObjectId; token: string; }) => { - try { - interface Query { - type: string; - email?: string; - phoneNumber?: string; - organization?: Types.ObjectId; - } + interface Query { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + } - const query: Query = { type }; + const query: Query = { type }; - if (email) { query.email = email; } - if (phoneNumber) { query.phoneNumber = phoneNumber; } - if (organizationId) { query.organization = organizationId; } + if (email) { query.email = email; } + if (phoneNumber) { query.phoneNumber = phoneNumber; } + if (organizationId) { query.organization = organizationId; } - const tokenData = await TokenData.findOneAndDelete(query); - - if (!tokenData) throw new Error('Failed to find token to validate'); - - if (tokenData.expiresAt < new Date()) throw new Error('Token has expired'); - - const isValid = await bcrypt.compare(token, tokenData.tokenHash); - if (!isValid) throw UnauthorizedRequestError({ + const tokenData = await TokenData.findOne(query).select('+tokenHash'); + + if (!tokenData) throw new Error('Failed to find token to validate'); + + if (tokenData.expiresAt < new Date()) { + await TokenData.findByIdAndDelete(tokenData._id); + throw ForbiddenRequestError({ + message: 'Failed token data validation due to token is no longer valid' + }); + } + + const isValid = await bcrypt.compare(token, tokenData.tokenHash); + if (!isValid) { + throw ForbiddenRequestError({ message: 'Failed token data validation due to incorrect token' }); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - "Failed to validate token data" - ); } + + await TokenData.findByIdAndDelete(tokenData._id); } export { diff --git a/backend/src/middleware/requestErrorHandler.ts b/backend/src/middleware/requestErrorHandler.ts index 50044387e..f70b989d2 100644 --- a/backend/src/middleware/requestErrorHandler.ts +++ b/backend/src/middleware/requestErrorHandler.ts @@ -1,11 +1,12 @@ import { ErrorRequestHandler } from "express"; import * as Sentry from '@sentry/node'; -import { InternalServerError } from "../utils/errors"; +import { InternalServerError, UnauthorizedRequestError } from "../utils/errors"; import { getLogger } from "../utils/logger"; import RequestError, { LogLevel } from "../utils/requestError"; import { NODE_ENV } from "../config"; +import { TokenExpiredError } from 'jsonwebtoken'; export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | Error, req, res, next) => { if (res.headersSent) return next(); @@ -16,7 +17,9 @@ export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | E } //TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError - if (!(error instanceof RequestError)) { + if (error instanceof TokenExpiredError) { + error = UnauthorizedRequestError({ stack: error.stack, message: 'Token expired' }); + } else if (!(error instanceof RequestError)) { error = InternalServerError({ context: { exception: error.message }, stack: error.stack }) getLogger('backend-main').log((error).levelName.toLowerCase(), (error).message) } diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 7ded52d8a..1cfcbc00d 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -1,5 +1,37 @@ --- title: "Circle CI" +description: "How to automatically sync secrets from Infisical into your CircleCI project." --- -Coming soon. +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for CircleCI + +Obtain a Fly.io access token in Access Tokens + +![integrations fly dashboard](../../images/integrations-flyio-dashboard.png) +![integrations fly token](../../images/integrations-flyio-token.png) + +Press on the Fly.io tile and input your Fly.io access token to grant Infisical access to your Fly.io account. + +![integrations fly authorization](../../images/integrations-flyio-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Fly.io app and press create integration to start syncing secrets to Fly.io. + +![integrations fly](../../images/integrations-flyio-create.png) +![integrations fly](../../images/integrations-flyio.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 56943db3f..439564258 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -21,6 +21,8 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | +| [GitLab Pipeline](/integrations/cicd/gitlab) | CI/CD | Available | +| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | @@ -39,8 +41,6 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | GCP | Cloud | Coming soon | | Azure | Cloud | Coming soon | | DigitalOcean | Cloud | Coming soon | -| [GitLab Pipeline](/integrations/cicd/gitlab) | CI/CD | Available | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Coming soon | | TravisCI | CI/CD | Coming soon | | GitHub Actions | CI/CD | Coming soon | | Jenkins | CI/CD | Coming soon | diff --git a/frontend/public/locales/en/signup.json b/frontend/public/locales/en/signup.json index bee993727..5fa5742e3 100644 --- a/frontend/public/locales/en/signup.json +++ b/frontend/public/locales/en/signup.json @@ -9,9 +9,9 @@ "step1-start": "Let's get started", "step1-privacy": "By creating an account, you agree to our Terms and have read and acknowledged the Privacy Policy.", "step1-submit": "Get Started", - "step2-message": "We've sent a verification email to", + "step2-message": "We've sent a verification code to", "step2-code-error": "Oops. Your code is wrong. Please try again.", - "step2-resend-alert": "Don't see the email?", + "step2-resend-alert": "Don't see the code?", "step2-resend-submit": "Resend", "step2-resend-progress": "Resending...", "step2-spam-alert": "Make sure to check your spam inbox.", diff --git a/frontend/public/locales/fr/signup.json b/frontend/public/locales/fr/signup.json index 48b36e203..63d869193 100644 --- a/frontend/public/locales/fr/signup.json +++ b/frontend/public/locales/fr/signup.json @@ -11,7 +11,7 @@ "step1-submit": "C'est parti", "step2-message": "Nous avons envoyé un email de vérification à", "step2-code-error": "Oops. Votre code est faux. Veuillez réessayer.", - "step2-resend-alert": "Vous ne voyez pas l'email?", + "step2-resend-alert": "Vous ne voyez pas le code?", "step2-resend-submit": "Renvoyer", "step2-resend-progress": "Envoie en cours...", "step2-spam-alert": "Assurez-vous de vérifier vos spams.", diff --git a/frontend/public/locales/tr/signup.json b/frontend/public/locales/tr/signup.json index 4a0778335..663b6b72b 100644 --- a/frontend/public/locales/tr/signup.json +++ b/frontend/public/locales/tr/signup.json @@ -11,7 +11,7 @@ "step1-submit": "Başla", "step2-message": "Şu adrese bir doğrulama maili yolladık", "step2-code-error": "Tüh. Kodun hatalı. Lütfen tekrar dene.", - "step2-resend-alert": "Mail ulaşamadı mı?", + "step2-resend-alert": "Kodu ulaşamadı mı?", "step2-resend-submit": "Tekrar Yolla", "step2-resend-progress": "Tekrar yollanıyor...", "step2-spam-alert": "Spam kutunuzu kontrol ettiğinizden emin olun.", diff --git a/frontend/src/components/login/MFAStep.tsx b/frontend/src/components/login/MFAStep.tsx index fa876ac5a..8793064b1 100644 --- a/frontend/src/components/login/MFAStep.tsx +++ b/frontend/src/components/login/MFAStep.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import attemptLoginMfa from '@app/components/utilities/attemptLoginMfa'; -import resendMfaToken from '@app/pages/api/auth/resendMfaToken'; +import { useSendMfaToken } from '@app/hooks/api/auth'; import Button from '../basic/buttons/Button'; import Error from '../basic/Error'; @@ -50,6 +50,8 @@ export default function MFAStep({ const [mfaCode, setMfaCode] = useState(''); const [codeError, setCodeError] = useState(false); + const sendMfaToken = useSendMfaToken(); + const { t } = useTranslation(); const handleLoginMfa = async () => { @@ -72,15 +74,14 @@ export default function MFAStep({ } catch (err) { console.error(err); + setIsLoading(false); setCodeError(true); } } const handleResendMfaCode = async () => { try { - await resendMfaToken({ - email - }); + await sendMfaToken.mutateAsync({ email }); } catch (err) { console.error(err); } diff --git a/frontend/src/components/navigation/NavBarDashboard.tsx b/frontend/src/components/navigation/NavBarDashboard.tsx index 92cb72973..e31b04ed0 100644 --- a/frontend/src/components/navigation/NavBarDashboard.tsx +++ b/frontend/src/components/navigation/NavBarDashboard.tsx @@ -87,7 +87,6 @@ export default function Navbar() { }, []); const closeApp = async () => { - console.log('Logging out...'); await logout(); router.push('/login'); }; diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index e13ba01d7..a7aad081c 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -161,6 +161,8 @@ export default function UserInfoStep({ organizationName: `${firstName}'s organization` }); + // unset signup JWT token and set JWT token + SecurityClient.setSignupToken(''); SecurityClient.setToken(response.token); saveTokenToLocalStorage({ @@ -174,14 +176,18 @@ export default function UserInfoStep({ privateKey }); - incrementStep(); - const userOrgs = await getOrganizations(); - await ProjectService.initProject({ - organizationId: userOrgs[0]?._id, + const orgId = userOrgs[0]?._id; + const project = await ProjectService.initProject({ + organizationId: orgId, projectName: 'Example Project' }); + localStorage.setItem('orgData.id', orgId); + localStorage.setItem('projectData.id', project._id); + + incrementStep(); + } catch (error) { setIsLoading(false); console.error(error); diff --git a/frontend/src/components/utilities/SecurityClient.ts b/frontend/src/components/utilities/SecurityClient.ts index f1ba05ed9..1b513a3ff 100644 --- a/frontend/src/components/utilities/SecurityClient.ts +++ b/frontend/src/components/utilities/SecurityClient.ts @@ -1,7 +1,15 @@ -import { getAuthToken, setAuthToken , setMfaTempToken } from '@app/reactQuery'; +import { + getAuthToken, + setAuthToken, + setMfaTempToken, + setSignupTempToken} from '@app/reactQuery'; // depreciated: go for apiRequest module in config/api export default class SecurityClient { + static setSignupToken(tokenStr: string) { + setSignupTempToken(tokenStr); + } + static setMfaToken(tokenStr: string) { setMfaTempToken(tokenStr); } diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index f6ab84715..464a4a4fe 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -44,7 +44,7 @@ const attemptLogin = async ( client.setSalt(salt); client.setServerPublicKey(serverPublicKey); const clientProof = client.getProof(); // called M1 - + const { mfaEnabled, encryptionVersion, @@ -65,7 +65,7 @@ const attemptLogin = async ( // case: MFA is enabled // set temporary (MFA) JWT token - SecurityClient.setToken(token); + SecurityClient.setMfaToken(token); resolve({ mfaEnabled, @@ -113,40 +113,14 @@ const attemptLogin = async ( const userOrgs = await getOrganizations(); const orgId = userOrgs[0]._id; localStorage.setItem('orgData.id', orgId); - + const orgUserProjects = await getOrganizationUserProjects({ orgId }); - 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 (orgUserProjects.length > 0) { + localStorage.setItem('projectData.id', orgUserProjects[0]._id); + } if (email) { telemetry.identify(email); @@ -166,100 +140,4 @@ const attemptLogin = async ( }); }; -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 +export default attemptLogin; \ No newline at end of file diff --git a/frontend/src/components/utilities/attemptLoginMfa.ts b/frontend/src/components/utilities/attemptLoginMfa.ts index 318c33168..8bec76b86 100644 --- a/frontend/src/components/utilities/attemptLoginMfa.ts +++ b/frontend/src/components/utilities/attemptLoginMfa.ts @@ -3,6 +3,8 @@ import jsrp from 'jsrp'; import login1 from '@app/pages/api/auth/Login1'; import verifyMfaToken from '@app/pages/api/auth/verifyMfaToken'; +import getOrganizations from '@app/pages/api/organization/getOrgs'; +import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserProjects'; import KeyService from '@app/services/KeyService'; import { saveTokenToLocalStorage } from './saveTokenToLocalStorage'; @@ -51,7 +53,8 @@ const attemptLoginMfa = async ({ mfaToken }); - // set JWT token + // unset temporary (MFA) JWT token and set JWT token + SecurityClient.setMfaToken(''); SecurityClient.setToken(token); const privateKey = await KeyService.decryptPrivateKey({ @@ -77,6 +80,18 @@ const attemptLoginMfa = async ({ privateKey }); + // 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 orgUserProjects = await getOrganizationUserProjects({ + orgId + }); + localStorage.setItem('projectData.id', orgUserProjects[0]._id); + resolve(true); } catch (err) { reject(err); diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index 7234da684..ee44dc147 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -1,6 +1,9 @@ import axios from 'axios'; -import { getAuthToken } from '@app/reactQuery'; +import { + getAuthToken, + getMfaTempToken, + getSignupTempToken} from '@app/reactQuery'; export const apiRequest = axios.create({ baseURL: '/', @@ -10,11 +13,17 @@ export const apiRequest = axios.create({ }); apiRequest.interceptors.request.use((config) => { + const signupTempToken = getSignupTempToken(); + const mfaTempToken = getMfaTempToken(); const token = getAuthToken(); - console.log('interceptors'); - console.log('token', token); - console.log('config.headers', config.headers); - if (token && config.headers) { + + if (signupTempToken && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${signupTempToken}`; + } else if (mfaTempToken && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${mfaTempToken}`; + } else if (token && config.headers) { // eslint-disable-next-line no-param-reassign config.headers.Authorization = `Bearer ${token}`; } diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 5b7837303..de14c3bf4 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -1 +1,4 @@ -export { useGetAuthToken } from './queries'; +export { + useGetAuthToken, + useSendMfaToken, + useVerifyMfaToken} from './queries' diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index ceae44ed8..23e0c50fc 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -1,14 +1,39 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; import { setAuthToken } from '@app/reactQuery'; -import { GetAuthTokenAPI } from './types'; +import { + GetAuthTokenAPI, + SendMfaTokenDTO, + VerifyMfaTokenDTO, + VerifyMfaTokenRes} from './types'; const authKeys = { getAuthToken: ['token'] as const }; +export const useSendMfaToken = () => { + return useMutation<{}, {}, SendMfaTokenDTO>({ + mutationFn: async ({ email }) => { + const { data } = await apiRequest.post('/api/v2/auth/mfa/send', { email }); + return data; + } + }); +} + +export const useVerifyMfaToken = () => { + return useMutation({ + mutationFn: async ({ email, mfaCode }) => { + const { data } = await apiRequest.post('/api/v2/auth/mfa/verify', { + email, + mfaToken: mfaCode + }); + return data; + } + }); +} + // Refresh token is set as cookie when logged in // Using that we fetch the auth bearer token needed for auth calls const fetchAuthToken = async () => { diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 998df8a5d..3d14c19ff 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -1,3 +1,24 @@ export type GetAuthTokenAPI = { token: string; }; + +export type SendMfaTokenDTO = { + email: string; +} + +export type VerifyMfaTokenDTO = { + email: string; + mfaCode: string; +} + +export type VerifyMfaTokenRes = { + encryptionVersion: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; +} \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1a2dc2cfa..032d1f3a5 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -93,6 +93,7 @@ export const AppLayout = ({ children }: LayoutProps) => { // Placing the localstorage as much as possible // Wait till tony integrates the azure and its launched useEffect(() => { + // Put a user in a workspace if they're not in one yet const putUserInWorkSpace = async () => { if (tempLocalStorage('orgData.id') === '') { diff --git a/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx b/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx index c6762f5bd..8ceb24098 100644 --- a/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx +++ b/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx @@ -75,8 +75,18 @@ export const Navbar = () => { const closeApp = async () => { try { - console.log('Logging out...'); + console.log('Logging out...') await logout.mutateAsync(); + localStorage.removeItem('protectedKey'); + localStorage.removeItem('protectedKeyIV'); + localStorage.removeItem('protectedKeyTag'); + localStorage.removeItem('publicKey'); + localStorage.removeItem('encryptedPrivateKey'); + localStorage.removeItem('iv'); + localStorage.removeItem('tag'); + localStorage.removeItem('PRIVATE_KEY'); + localStorage.removeItem('orgData.id'); + localStorage.removeItem('projectData.id'); router.push('/login'); } catch (error) { console.error(error); diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts index a76307569..946505887 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts @@ -1,4 +1,5 @@ -import SecurityClient from '@app/components/utilities/SecurityClient'; + +import { apiRequest } from "@app/config/request"; interface Props { email: string; @@ -35,7 +36,7 @@ interface Props { * @param {string} obj.verifier * @returns */ -const completeAccountInformationSignup = ({ +const completeAccountInformationSignup = async ({ email, firstName, lastName, @@ -49,32 +50,24 @@ const completeAccountInformationSignup = ({ salt, verifier, organizationName -}: Props) => SecurityClient.fetchCall('/api/v2/signup/complete-account/signup', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email, - firstName, - lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - 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'); +}: Props) => { + const { data } = await apiRequest.post('/api/v2/signup/complete-account/signup', { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier, + organizationName }); + return data; +} + export default completeAccountInformationSignup; diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts index 62a5b503f..2307bbb74 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts @@ -1,3 +1,5 @@ +import { apiRequest } from "@app/config/request"; + interface Props { email: string; firstName: string; @@ -11,9 +13,12 @@ interface Props { encryptedPrivateKeyTag: string; salt: string; verifier: string; - token: string; } +// missing token? +// TODO: add to SecurityClient + + /** * This function is called in the end of the signup process. * It sends all the necessary nformation to the server. @@ -30,7 +35,7 @@ interface Props { * @param {string} obj.token - token that confirms a user's identity * @returns */ -const completeAccountInformationSignupInvite = ({ +const completeAccountInformationSignupInvite = async ({ email, firstName, lastName, @@ -42,28 +47,24 @@ const completeAccountInformationSignupInvite = ({ encryptedPrivateKeyIV, encryptedPrivateKeyTag, salt, - verifier, - token -}: Props) => fetch('/api/v2/signup/complete-account/invite', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ - email, - firstName, - lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier - }) + verifier +}: Props) => { + const { data } = await apiRequest.post('/api/v2/signup/complete-account/invite', { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier }); + return data; +} + export default completeAccountInformationSignupInvite; diff --git a/frontend/src/pages/api/auth/Logout.ts b/frontend/src/pages/api/auth/Logout.ts index 71f81b1bd..d9f50fee2 100644 --- a/frontend/src/pages/api/auth/Logout.ts +++ b/frontend/src/pages/api/auth/Logout.ts @@ -4,14 +4,16 @@ import SecurityClient from '@app/components/utilities/SecurityClient'; * This route logs the user out. Note: the user should authorized to do this. * We first try to log out - if the authorization fails (response.status = 401), we refetch the new token, and then retry */ -const logout = async () => - SecurityClient.fetchCall('/api/v1/auth/logout', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include' - }).then((res) => { +const logout = async () => { + try { + const res = await SecurityClient.fetchCall('/api/v1/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }); + if (res?.status === 200) { SecurityClient.setToken(''); // Delete the cookie by not setting a value; Alternatively clear the local storage @@ -23,12 +25,17 @@ const logout = async () => localStorage.removeItem('iv'); localStorage.removeItem('tag'); localStorage.removeItem('PRIVATE_KEY'); + localStorage.removeItem('orgData.id'); + localStorage.removeItem('projectData.id'); - console.log('User logged out', res); return res; } - console.log('Failed to log out'); - return undefined; - }); + + } catch (error) { + console.log('Error logging out', error); + } + + return undefined; +}; export default logout; diff --git a/frontend/src/pages/api/auth/resendMfaToken.ts b/frontend/src/pages/api/auth/resendMfaToken.ts deleted file mode 100644 index 40f567f90..000000000 --- a/frontend/src/pages/api/auth/resendMfaToken.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 index 8df09a826..7fdd92183 100644 --- a/frontend/src/pages/api/auth/verifyMfaToken.ts +++ b/frontend/src/pages/api/auth/verifyMfaToken.ts @@ -1,4 +1,4 @@ -import SecurityClient from "@app/components/utilities/SecurityClient"; +import { apiRequest } from "@app/config/request"; /** * Verify MFA token [mfaToken] for user with email [email] @@ -13,23 +13,13 @@ const verifyMfaToken = async ({ }: { 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'); - }); - - +}) => { + const { data } = await apiRequest.post('/api/v2/auth/mfa/verify', { + email, + mfaToken + }); + + return data; +} export default verifyMfaToken; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index f08bd325b..1b380bfda 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -17,7 +17,6 @@ export default function Login() { const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false); const [step, setStep] = useState(1); const { t } = useTranslation(); const lang = router.locale ?? 'en'; @@ -41,7 +40,6 @@ export default function Login() { } }; if (isLoggedIn()) { - setIsAlreadyLoggedIn(true); redirectToDashboard(); } }, []); @@ -72,10 +70,6 @@ export default function Login() { } } - if (isAlreadyLoggedIn) { - return null - } - return (
@@ -106,4 +100,4 @@ export default function Login() { ); } -export const getStaticProps = getTranslatedStaticProps(['auth', 'login']); +export const getStaticProps = getTranslatedStaticProps(['auth', 'login', 'signup']); diff --git a/frontend/src/pages/signup.tsx b/frontend/src/pages/signup.tsx index dbd610979..62d0f4a47 100644 --- a/frontend/src/pages/signup.tsx +++ b/frontend/src/pages/signup.tsx @@ -59,7 +59,8 @@ export default function SignUp() { // Checking if the code matches the email. const response = await checkEmailVerificationCode({ email, code }); if (response.status === 200) { - SecurityClient.setToken((await response.json()).token); + const {token} = await response.json(); + SecurityClient.setSignupToken(token); setStep(3); } else { setCodeError(true); diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 425b9cf6c..0e5db027a 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -21,6 +21,10 @@ import passwordCheck from '@app/components/utilities/checks/PasswordCheck'; import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; import { deriveArgonKey } from '@app/components/utilities/cryptography/crypto'; import issueBackupKey from '@app/components/utilities/cryptography/issueBackupKey'; +import { saveTokenToLocalStorage } from '@app/components/utilities/saveTokenToLocalStorage'; +import SecurityClient from '@app/components/utilities/SecurityClient'; +import getOrganizations from '@app/pages/api/organization/getOrgs'; +import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserProjects'; import completeAccountInformationSignupInvite from './api/auth/CompleteAccountInformationSignupInvite'; import verifySignupInvite from './api/auth/VerifySignupInvite'; @@ -29,7 +33,6 @@ import verifySignupInvite from './api/auth/VerifySignupInvite'; const client = new jsrp.client(); export default function SignupInvite() { - console.log('SignupInvite'); const [password, setPassword] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); @@ -82,13 +85,6 @@ export default function SignupInvite() { const privateKey = encodeBase64(secretKeyUint8Array); const publicKey = encodeBase64(publicKeyUint8Array); - // const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - // text: PRIVATE_KEY, - // secret: password - // .slice(0, 32) - // .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - // }); - localStorage.setItem('PRIVATE_KEY', privateKey); client.init( @@ -134,8 +130,9 @@ export default function SignupInvite() { secret: Buffer.from(derivedKey.hash) }); - console.log('SignupInvite A'); - let response = await completeAccountInformationSignupInvite({ + const { + token: jwtToken + } = await completeAccountInformationSignupInvite({ email, firstName, lastName, @@ -147,24 +144,30 @@ export default function SignupInvite() { encryptedPrivateKeyIV, encryptedPrivateKeyTag, salt: result.salt, - verifier: result.verifier, - token: verificationToken + verifier: result.verifier }); - console.log('SignupInvite B'); + + // unset temporary signup JWT token and set JWT token + SecurityClient.setSignupToken(''); + SecurityClient.setToken(jwtToken); - // if everything works, go the main dashboard page. - if (!errorCheck && response.status === 200) { - response = await response.json(); + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + privateKey + }); - console.log('SignupInvite C'); - localStorage.setItem('publicKey', publicKey); - localStorage.setItem('encryptedPrivateKey', encryptedPrivateKey); - localStorage.setItem('iv', encryptedPrivateKeyIV); - localStorage.setItem('tag', encryptedPrivateKeyTag); - console.log('SignupInvite D'); + const userOrgs = await getOrganizations(); - setStep(3); - } + const orgId = userOrgs[0]._id; + localStorage.setItem('orgData.id', orgId); + + setStep(3); } catch (error) { setIsLoading(false); console.error(error); @@ -197,7 +200,7 @@ export default function SignupInvite() { // user will have temp token if doesn't have an account // then continue with account setup workflow if (res?.token) { - setVerificationToken(res.token); + SecurityClient.setSignupToken(res.token); setStep(2); } else { // user will be redirected to dashboard diff --git a/frontend/src/reactQuery.ts b/frontend/src/reactQuery.ts index 7bb83b111..d65baa27f 100644 --- a/frontend/src/reactQuery.ts +++ b/frontend/src/reactQuery.ts @@ -1,6 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; // this is saved in react-query cache +export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ['infisical__signup-temp-token']; export const MFA_TEMP_TOKEN_CACHE_KEY = ['infisical__mfa-temp-token']; export const AUTH_TOKEN_CACHE_KEY = ['infisical__auth-token']; @@ -14,12 +15,16 @@ export const queryClient = new QueryClient({ }); // set token in memory cache +export const setSignupTempToken = (token: string) => + queryClient.setQueryData(SIGNUP_TEMP_TOKEN_CACHE_KEY, token); + export const setMfaTempToken = (token: string) => queryClient.setQueryData(MFA_TEMP_TOKEN_CACHE_KEY, token); export const setAuthToken = (token: string) => queryClient.setQueryData(AUTH_TOKEN_CACHE_KEY, token); +export const getSignupTempToken = () => queryClient.getQueryData(SIGNUP_TEMP_TOKEN_CACHE_KEY) as string; export const getMfaTempToken = () => queryClient.getQueryData(MFA_TEMP_TOKEN_CACHE_KEY) as string; export const getAuthToken = () => queryClient.getQueryData(AUTH_TOKEN_CACHE_KEY) as string; From b710944630cad587ef953d0c8087628aa32281f2 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 15 Feb 2023 01:29:40 +0700 Subject: [PATCH 07/11] Add more edge-cases to MFA --- backend/src/controllers/v2/authController.ts | 10 ---- backend/src/helpers/token.ts | 50 ++++++++++++++++--- backend/src/middleware/requestErrorHandler.ts | 4 +- backend/src/models/tokenData.ts | 4 ++ frontend/src/components/login/LoginStep.tsx | 1 - frontend/src/components/login/MFAStep.tsx | 22 +++++++- 6 files changed, 70 insertions(+), 21 deletions(-) diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 1e9a73da0..fa41eda73 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -243,7 +243,6 @@ export const sendMfaToken = async (req: Request, res: Response) => { * @param res */ export const verifyMfaToken = async (req: Request, res: Response) => { - try { const { email, mfaToken } = req.body; await TokenService.validateToken({ @@ -296,15 +295,6 @@ export const verifyMfaToken = async (req: Request, res: Response) => { resObj.protectedKeyTag = user.protectedKeyTag; } - // case: user does not have MFA enabled - // return (access) token in response return res.status(200).send(resObj); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to authenticate. Try again?' - }); - } } diff --git a/backend/src/helpers/token.ts b/backend/src/helpers/token.ts index 026bb4ae7..ca8838151 100644 --- a/backend/src/helpers/token.ts +++ b/backend/src/helpers/token.ts @@ -12,7 +12,7 @@ import { import { SALT_ROUNDS } from '../config'; -import { ForbiddenRequestError } from '../utils/errors'; +import { UnauthorizedRequestError } from '../utils/errors'; /** * Create and store a token in the database for purpose [type] @@ -34,7 +34,7 @@ const createTokenHelper = async ({ phoneNumber?: string; organizationId?: Types.ObjectId }) => { - let token, expiresAt; + let token, expiresAt, triesLeft; try { // generate random token based on specified token use-case // type [type] @@ -47,6 +47,7 @@ const createTokenHelper = async ({ case TOKEN_EMAIL_MFA: // generate random 6-digit code token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); + triesLeft = 5; expiresAt = new Date((new Date()).getTime() + 300000); break; case TOKEN_EMAIL_ORG_INVITATION: @@ -78,6 +79,7 @@ const createTokenHelper = async ({ phoneNumber?: string; organization?: Types.ObjectId; tokenHash: string; + triesLeft?: number; expiresAt: Date; } @@ -100,6 +102,10 @@ const createTokenHelper = async ({ query.organization = organizationId update.organization = organizationId } + + if (triesLeft) { + update.triesLeft = triesLeft; + } await TokenData.findOneAndUpdate( query, @@ -157,19 +163,51 @@ const validateTokenHelper = async ({ if (!tokenData) throw new Error('Failed to find token to validate'); if (tokenData.expiresAt < new Date()) { + // case: token expired await TokenData.findByIdAndDelete(tokenData._id); - throw ForbiddenRequestError({ - message: 'Failed token data validation due to token is no longer valid' + throw UnauthorizedRequestError({ + message: 'MFA session expired. Please log in again', + context: { + code: 'mfa_expired' + } }); } const isValid = await bcrypt.compare(token, tokenData.tokenHash); if (!isValid) { - throw ForbiddenRequestError({ - message: 'Failed token data validation due to incorrect token' + // case: token is not valid + if (tokenData?.triesLeft !== undefined) { + // case: token has a try-limit + if (tokenData.triesLeft === 1) { + // case: token is out of tries + await TokenData.findByIdAndDelete(tokenData._id); + } else { + // case: token has more than 1 try left + await TokenData.findByIdAndUpdate(tokenData._id, { + triesLeft: tokenData.triesLeft - 1 + }, { + new: true + }); + } + + throw UnauthorizedRequestError({ + message: 'MFA code is invalid', + context: { + code: 'mfa_invalid', + triesLeft: tokenData.triesLeft - 1 + } + }); + } + + throw UnauthorizedRequestError({ + message: 'MFA code is invalid', + context: { + code: 'mfa_invalid' + } }); } + // case: token is valid await TokenData.findByIdAndDelete(tokenData._id); } diff --git a/backend/src/middleware/requestErrorHandler.ts b/backend/src/middleware/requestErrorHandler.ts index f70b989d2..653c0df2e 100644 --- a/backend/src/middleware/requestErrorHandler.ts +++ b/backend/src/middleware/requestErrorHandler.ts @@ -17,9 +17,7 @@ export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | E } //TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError - if (error instanceof TokenExpiredError) { - error = UnauthorizedRequestError({ stack: error.stack, message: 'Token expired' }); - } else if (!(error instanceof RequestError)) { + if (!(error instanceof RequestError)) { error = InternalServerError({ context: { exception: error.message }, stack: error.stack }) getLogger('backend-main').log((error).levelName.toLowerCase(), (error).message) } diff --git a/backend/src/models/tokenData.ts b/backend/src/models/tokenData.ts index ad23d56aa..2ee39c694 100644 --- a/backend/src/models/tokenData.ts +++ b/backend/src/models/tokenData.ts @@ -6,6 +6,7 @@ export interface ITokenData { phoneNumber?: string; organization?: Types.ObjectId; tokenHash: string; + triesLeft?: number; expiresAt: Date; createdAt: Date; updatedAt: Date; @@ -37,6 +38,9 @@ const tokenDataSchema = new Schema({ select: false, required: true }, + triesLeft: { + type: Number + }, expiresAt: { type: Date, expires: 0, diff --git a/frontend/src/components/login/LoginStep.tsx b/frontend/src/components/login/LoginStep.tsx index 42d3e6d22..b19bafdd9 100644 --- a/frontend/src/components/login/LoginStep.tsx +++ b/frontend/src/components/login/LoginStep.tsx @@ -63,7 +63,6 @@ export default function LoginStep ({ } } catch (err) { - console.error(err); setLoginError(true); } diff --git a/frontend/src/components/login/MFAStep.tsx b/frontend/src/components/login/MFAStep.tsx index 8793064b1..c7e1e70e9 100644 --- a/frontend/src/components/login/MFAStep.tsx +++ b/frontend/src/components/login/MFAStep.tsx @@ -30,6 +30,18 @@ const props = { } } as const; +interface VerifyMfaTokenError { + response: { + data: { + context: { + code: string; + triesLeft: number; + } + }, + status: number; + } +} + /** * 2nd step of login - users enter their MFA code * @param {Object} obj @@ -73,7 +85,15 @@ export default function MFAStep({ } } catch (err) { - console.error(err); + const error = err as VerifyMfaTokenError; + + if (error?.response?.status === 500) { + window.location.reload(); + } else if (error?.response?.data?.context?.triesLeft === 0) { + window.location.reload(); + router.push('/login'); + } + setIsLoading(false); setCodeError(true); } From 754ea09400d9ebef218f06c863b676ac956cf289 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 15 Feb 2023 10:28:23 +0700 Subject: [PATCH 08/11] Add simple tries left for MFA --- frontend/public/locales/en/mfa.json | 28 ++++++++++++++++++++ frontend/public/locales/fr/mfa.json | 28 ++++++++++++++++++++ frontend/public/locales/ko/mfa.json | 21 +++++++++++++++ frontend/public/locales/pt-BR/mfa.json | 21 +++++++++++++++ frontend/public/locales/tr/mfa.json | 28 ++++++++++++++++++++ frontend/src/components/login/MFAStep.tsx | 32 +++++++++++++---------- frontend/src/pages/login.tsx | 2 +- 7 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 frontend/public/locales/en/mfa.json create mode 100644 frontend/public/locales/fr/mfa.json create mode 100644 frontend/public/locales/ko/mfa.json create mode 100644 frontend/public/locales/pt-BR/mfa.json create mode 100644 frontend/public/locales/tr/mfa.json diff --git a/frontend/public/locales/en/mfa.json b/frontend/public/locales/en/mfa.json new file mode 100644 index 000000000..2cc50c509 --- /dev/null +++ b/frontend/public/locales/en/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "Sign Up", + "og-title": "Replace .env files with 1 line of code. Sign Up for Infisical in 3 minutes.", + "og-description": "Infisical a simple end-to-end encrypted platform that enables teams to sync and manage API-keys and environemntal variables. Works with Node.js, Next.js, Gatsby, Nest.js...", + "signup": "Sign Up", + "already-have-account": "Have an account? Log in", + "forgot-password": "Forgot your password?", + "verify": "Verify", + "step1-start": "Let's get started", + "step1-privacy": "By creating an account, you agree to our Terms and have read and acknowledged the Privacy Policy.", + "step1-submit": "Get Started", + "step2-message": "We've sent a verification code to", + "step2-code-error": "Oops. Your code is wrong. Tries left:", + "step2-resend-alert": "Don't see the code?", + "step2-resend-submit": "Resend", + "step2-resend-progress": "Resending...", + "step2-spam-alert": "Make sure to check your spam inbox.", + "step3-message": "Almost there!", + "step4-message": "Save your Emergency Kit", + "step4-description1": "If you get locked out of your account, your Emergency Kit is the only way to sign in.", + "step4-description2": "We recommend you download it and keep it somewhere safe.", + "step4-description3": "It contains your Secret Key which we cannot access or recover for you if you lose it.", + "step4-download": "Download PDF", + "step5-send-invites": "Send Invites", + "step5-invite-team": "Invite your team", + "step5-subtitle": "Infisical is meant to be used with your teammates. Invite them to test it out.", + "step5-skip": "Skip" +} diff --git a/frontend/public/locales/fr/mfa.json b/frontend/public/locales/fr/mfa.json new file mode 100644 index 000000000..60e3201ed --- /dev/null +++ b/frontend/public/locales/fr/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "S'inscrire", + "og-title": "Remplacez les fichiers .env par 1 ligne de code. Inscrivez-vous à Infisical en 3 minutes.", + "og-description": "Infisical, une plate-forme simple et chiffré de bout en bout qui permet aux équipes de synchroniser et de gérer des clefs API et des variables d'environnement. Fonctionne avec Node.js, Next.js, Gatsby, Nest.js ...", + "signup": "S'inscrire", + "already-have-account": "Déjà inscris? Se connecter", + "forgot-password": "Mot de passe oublié?", + "verify": "Vérifier", + "step1-start": "Bon, on commence!", + "step1-privacy": "En créant votre compte, vous acceptez nos conditions et avez lu et reconnu notre politique de confidentialité.", + "step1-submit": "C'est parti", + "step2-message": "Nous avons envoyé un email de vérification à", + "step2-code-error": "Oops. Votre code est faux. Essais restants:", + "step2-resend-alert": "Vous ne voyez pas le code?", + "step2-resend-submit": "Renvoyer", + "step2-resend-progress": "Envoie en cours...", + "step2-spam-alert": "Assurez-vous de vérifier vos spams.", + "step3-message": "Nous y sommes presque!", + "step4-message": "Enregistrez votre kit d'urgence", + "step4-description1": "Si vous n'arrivez plus à vous connecter à votre compte, votre kit d'urgence est le seul moyen d'y arriver.", + "step4-description2": "Nous vous recommandons de le télécharger et de le garder en sécurité.", + "step4-description3": "Il contient votre clef secrète que nous ne pouvons pas récupérer pour vous si vous la perdez.", + "step4-download": "Téléchargez le PDF", + "step5-send-invites": "Envoyer les invitations", + "step5-invite-team": "Invitez votre équipe", + "step5-subtitle": "Infisical a pour but d'être utilisé avec vos coéquipiers. Invitez-les à le tester.", + "step5-skip": "Passer" +} diff --git a/frontend/public/locales/ko/mfa.json b/frontend/public/locales/ko/mfa.json new file mode 100644 index 000000000..6c17a68cf --- /dev/null +++ b/frontend/public/locales/ko/mfa.json @@ -0,0 +1,21 @@ +{ + "title": "회원가입", + "og-title": "한줄의 코드르 .env파일을 교체하세요. 3분이면 가입할 수 있어요.", + "og-description": "Infisical은 팀원과 .env파일을 공유하고 연동할 수 있는 심플한 end-to-end 암호화 플렛폼입니다. Node.js, Next.js, Gatsby, Nest.js 와 같은 다양한 플렛폼에서 작동해요.", + "signup": "회원가입", + "already-have-account": "이미 계정이 있나요? 로그인하기", + "forgot-password": "비밀번호를 잊으셨나요?", + "verify": "인증", + "step1-start": "시작하기", + "step1-privacy": "회원가입시 약관과 개인 정보 보호 정책을 읽고 동의한 것으로 간주합니다.", + "step1-submit": "시작하기", + "step2-message": "{{email}}로 인증 메일을 전송하였습니다{{email}}", + "step2-code-error": "코드가 잘못된 것 같아요. 남은 시도:", + "step2-spam-alert": "스팸함에 메일이 있지는 않은지 확인하세요", + "step3-message": "거의다 끝났어요!", + "step4-message": "긴급복구 키트 저장하기", + "step4-description1": "계정이 잠겼을 경우 비상 키트를 사용하여 로그인할 수 있어요.", + "step4-description2": "다운로드 후 안전한 곳에 보관하는 것을 추천합니다.", + "step4-description3": "분실시 접근하거나 복구할 수 없는 시크릿 키가 포함되어 있어요.", + "step4-download": "PDF 다운로드" +} diff --git a/frontend/public/locales/pt-BR/mfa.json b/frontend/public/locales/pt-BR/mfa.json new file mode 100644 index 000000000..59acb3055 --- /dev/null +++ b/frontend/public/locales/pt-BR/mfa.json @@ -0,0 +1,21 @@ +{ + "title": "Inscrever-se", + "og-title": "Substitua os arquivos .env por 1 linha de código. Cadastre-se no Infisical em 3 minutos.", + "og-description": "Infisical é uma plataforma criptografada de ponta a ponta simples que permite que as equipes sincronizem e gerenciem chaves de API e variáveis ambientais. Funciona com Node.js, Next.js, Gatsby, Nest.js...", + "signup": "Inscrever-se", + "already-have-account": "Possui uma conta? Conecte-se", + "forgot-password": "Esqueceu sua senha?", + "verify": "Verificar", + "step1-start": "Vamos começar", + "step1-privacy": "Ao criar uma conta, você concorda com nossos Termos e leu e reconheceu a Política de Privacidade.", + "step1-submit": "Iniciar", + "step2-message": "Enviamos um e-mail de verificação para{{email}}", + "step2-code-error": "Ops. Seu código está errado. Tentativas restantes:", + "step2-spam-alert": "Certifique-se de verificar sua caixa de entrada de spam.", + "step3-message": "Quase lá!", + "step4-message": "Guarde o seu Kit de Emergência", + "step4-description1": "Se sua conta for bloqueada, seu Kit de emergência é a única maneira de fazer login.", + "step4-description2": "Recomendamos que você faça o download e guarde-o em algum lugar seguro.", + "step4-description3": "Ele contém sua chave secreta que não podemos acessar ou recuperar para você se você a perder.", + "step4-download": "Baixar PDF" +} \ No newline at end of file diff --git a/frontend/public/locales/tr/mfa.json b/frontend/public/locales/tr/mfa.json new file mode 100644 index 000000000..fe4eb1654 --- /dev/null +++ b/frontend/public/locales/tr/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "Kayıt olun", + "og-title": "Tek satır kodla .env dosyalarını değiştirin. 3 dakika içerinde Infisical'a kayıt olun.", + "og-description": "Infisical takımların API anahtarlarını ve ortam değişkenlerini yönetmelerini ve senkronize etmelerini sağlayan basit, uçtan uca şifrelenmiş bir platformdur. Node.js, Next.js, Gatsby, Nest.js ve daha fazlası ile çalışır.", + "signup": "Kayıt ol", + "already-have-account": "Hesabın var mı? Giriş yap", + "forgot-password": "Şifreni mi unuttun?", + "verify": "Doğrula", + "step1-start": "Hadi başlayalım", + "step1-privacy": "Hesap oluşturarak, Şartlarımızı ve Gizlilik Politikasını okuyup kabul etmiş olursunuz.", + "step1-submit": "Başla", + "step2-message": "Şu adrese bir doğrulama maili yolladık", + "step2-code-error": "Tüh. Kodun hatalı. Kalan denemeler:", + "step2-resend-alert": "Kodu ulaşamadı mı?", + "step2-resend-submit": "Tekrar Yolla", + "step2-resend-progress": "Tekrar yollanıyor...", + "step2-spam-alert": "Spam kutunuzu kontrol ettiğinizden emin olun.", + "step3-message": "Çok az kaldı!", + "step4-message": "Acil Durum Kitinizi kayıt edin", + "step4-description1": "Eğer hesabınıza erişemezseniz, Acil Durum Kitiniz giriş yapmanın tek yoludur.", + "step4-description2": "Bunu indirmenizi ve güvenli bir ortamda saklamanızı öneriyoruz.", + "step4-description3": "Kaybetmeniz durumunda bizim dahi erişemeyeceğimiz veya kurtaramayacağımız Gizli Anahtarınızı barındırır.", + "step4-download": "PDF'yi indir", + "step5-send-invites": "Davetleri yolla", + "step5-invite-team": "Takımını davet et", + "step5-subtitle": "Infisical takım arkadaşlarınız ile kullanılmak üzere yapılmıştır. Birlikte test etmek için onları davet edin.", + "step5-skip": "Atla" +} \ No newline at end of file diff --git a/frontend/src/components/login/MFAStep.tsx b/frontend/src/components/login/MFAStep.tsx index c7e1e70e9..0bd3a8927 100644 --- a/frontend/src/components/login/MFAStep.tsx +++ b/frontend/src/components/login/MFAStep.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import attemptLoginMfa from '@app/components/utilities/attemptLoginMfa'; +import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; import { useSendMfaToken } from '@app/hooks/api/auth'; import Button from '../basic/buttons/Button'; @@ -60,12 +61,12 @@ export default function MFAStep({ const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [mfaCode, setMfaCode] = useState(''); - const [codeError, setCodeError] = useState(false); - - const sendMfaToken = useSendMfaToken(); + const [triesLeft, setTriesLeft] = useState(undefined); const { t } = useTranslation(); + const sendMfaToken = useSendMfaToken(); + const handleLoginMfa = async () => { try { if (mfaCode.length !== 6) { @@ -89,13 +90,14 @@ export default function MFAStep({ if (error?.response?.status === 500) { window.location.reload(); - } else if (error?.response?.data?.context?.triesLeft === 0) { - window.location.reload(); - router.push('/login'); + } else if (error?.response?.data?.context?.triesLeft) { + setTriesLeft(error?.response?.data?.context?.triesLeft); + if (error.response.data.context.triesLeft === 0) { + window.location.reload(); + } } setIsLoading(false); - setCodeError(true); } } @@ -109,7 +111,7 @@ export default function MFAStep({ return ( -

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

+

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

{email}

- {codeError && } + {typeof triesLeft === 'number' && }
- {t('signup:step2-resend-alert')} + {t('mfa:step2-resend-alert')}
-

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

+

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

); } + +export const getStaticProps = getTranslatedStaticProps(['auth', 'mfa']); \ No newline at end of file diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 1b380bfda..ec0dbaebb 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -100,4 +100,4 @@ export default function Login() { ); } -export const getStaticProps = getTranslatedStaticProps(['auth', 'login', 'signup']); +export const getStaticProps = getTranslatedStaticProps(['auth', 'login', 'mfa']); From f2d7401d1df6682ac15f8f974479174a64cd83f2 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 16 Feb 2023 15:47:59 -0800 Subject: [PATCH 09/11] Add support for version 2 auth --- cli/go.mod | 8 +- cli/go.sum | 8 ++ cli/packages/api/api.go | 40 ++++++++++ cli/packages/api/model.go | 28 +++++++ cli/packages/cmd/login.go | 157 +++++++++++++++++++++++++------------- 5 files changed, 186 insertions(+), 55 deletions(-) diff --git a/cli/go.mod b/cli/go.mod index 6b7d92489..fd35082e3 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -7,8 +7,8 @@ require ( github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/spf13/cobra v1.6.1 - golang.org/x/crypto v0.3.0 - golang.org/x/term v0.3.0 + golang.org/x/crypto v0.6.0 + golang.org/x/term v0.5.0 ) require ( @@ -31,8 +31,8 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect - golang.org/x/net v0.2.0 // indirect - golang.org/x/sys v0.3.0 // indirect + golang.org/x/net v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect ) require ( diff --git a/cli/go.sum b/cli/go.sum index f7e17c62a..8a3818528 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -106,10 +106,14 @@ go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAV golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -123,9 +127,13 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 60cc7d07c..637fc1065 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -128,6 +128,46 @@ func CallGetSecretsV2(httpClient *resty.Client, request GetEncryptedSecretsV2Req return secretsResponse, nil } +func CallLogin1V2(httpClient *resty.Client, request GetLoginOneV2Request) (GetLoginOneV2Response, error) { + var loginOneV2Response GetLoginOneV2Response + response, err := httpClient. + R(). + SetResult(&loginOneV2Response). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/login1", config.INFISICAL_URL)) + + if err != nil { + return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V2: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V2: Unsuccessful response: [response=%s]", response) + } + + return loginOneV2Response, nil +} + +func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLoginTwoV2Response, error) { + var loginTwoV2Response GetLoginTwoV2Response + response, err := httpClient. + R(). + SetResult(&loginTwoV2Response). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/login2", config.INFISICAL_URL)) + + if err != nil { + return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V2: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V2: Unsuccessful response: [response=%s]", response) + } + + return loginTwoV2Response, nil +} + func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) { var workSpacesResponse GetWorkSpacesResponse response, err := httpClient. diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 7c9fbbf4e..5dd20b1df 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -263,3 +263,31 @@ type GetAccessibleEnvironmentsResponse struct { IsWriteDenied bool `json:"isWriteDenied"` } `json:"accessibleEnvironments"` } + +type GetLoginOneV2Request struct { + Email string `json:"email"` + ClientPublicKey string `json:"clientPublicKey"` +} + +type GetLoginOneV2Response struct { + ServerPublicKey string `json:"serverPublicKey"` + Salt string `json:"salt"` +} + +type GetLoginTwoV2Request struct { + Email string `json:"email"` + ClientProof string `json:"clientProof"` +} + +type GetLoginTwoV2Response struct { + MfaEnabled bool `json:"mfaEnabled"` + EncryptionVersion int `json:"encryptionVersion"` + Token string `json:"token"` + PublicKey string `json:"publicKey"` + EncryptedPrivateKey string `json:"encryptedPrivateKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + ProtectedKey string `json:"protectedKey"` + ProtectedKeyIV string `json:"protectedKeyIV"` + ProtectedKeyTag string `json:"protectedKeyTag"` +} diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index c37bdda08..c2d193d8a 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -13,7 +13,6 @@ import ( "regexp" "github.com/Infisical/infisical-merge/packages/api" - "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/srp" @@ -23,8 +22,23 @@ import ( "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/crypto/argon2" ) +type params struct { + memory uint32 + iterations uint32 + parallelism uint8 + saltLength uint32 + keyLength uint32 +} + +func generateFromPassword(password string, salt []byte, p *params) (hash []byte, err error) { + hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + + return hash, nil +} + // loginCmd represents the login command var loginCmd = &cobra.Command{ Use: "login", @@ -55,36 +69,100 @@ var loginCmd = &cobra.Command{ util.HandleError(err, "Unable to parse email and password for authentication") } - userCredentials, err := getFreshUserCredentials(email, password) + loginOneResponse, loginTwoResponse, err := getFreshUserCredentials(email, password) if err != nil { log.Infoln("Unable to authenticate with the provided credentials, please try again") log.Debugln(err) return } - encryptedPrivateKey, _ := base64.StdEncoding.DecodeString(userCredentials.EncryptedPrivateKey) - tag, err := base64.StdEncoding.DecodeString(userCredentials.Tag) - if err != nil { - util.HandleError(err) - } + var decryptedPrivateKey []byte - IV, err := base64.StdEncoding.DecodeString(userCredentials.IV) - if err != nil { - util.HandleError(err) - } + if loginTwoResponse.EncryptionVersion == 1 { + encryptedPrivateKey, _ := base64.StdEncoding.DecodeString(loginTwoResponse.EncryptedPrivateKey) + tag, err := base64.StdEncoding.DecodeString(loginTwoResponse.Tag) + if err != nil { + util.HandleError(err) + } - paddedPassword := fmt.Sprintf("%032s", password) - key := []byte(paddedPassword) + IV, err := base64.StdEncoding.DecodeString(loginTwoResponse.Iv) + if err != nil { + util.HandleError(err) + } - decryptedPrivateKey, err := crypto.DecryptSymmetric(key, encryptedPrivateKey, tag, IV) - if err != nil || len(decryptedPrivateKey) == 0 { - util.HandleError(err) + paddedPassword := fmt.Sprintf("%032s", password) + key := []byte(paddedPassword) + + decryptedPrivateKey, err := crypto.DecryptSymmetric(key, encryptedPrivateKey, tag, IV) + if err != nil || len(decryptedPrivateKey) == 0 { + util.HandleError(err) + } + } else if loginTwoResponse.EncryptionVersion == 2 { + protectedKey, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKey) + if err != nil { + util.HandleError(err) + } + + protectedKeyTag, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKeyTag) + if err != nil { + util.HandleError(err) + } + + protectedKeyIV, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKeyIV) + if err != nil { + util.HandleError(err) + } + + nonProtectedTag, err := base64.StdEncoding.DecodeString(loginTwoResponse.Tag) + if err != nil { + util.HandleError(err) + } + + nonProtectedIv, err := base64.StdEncoding.DecodeString(loginTwoResponse.Iv) + if err != nil { + util.HandleError(err) + } + + parameters := ¶ms{ + memory: 64 * 1024, + iterations: 3, + parallelism: 1, + keyLength: 32, + } + + derivedKey, err := generateFromPassword(password, []byte(loginOneResponse.Salt), parameters) + if err != nil { + util.HandleError(fmt.Errorf("unable to generate argon hash from password [err=%s]", err)) + } + + decryptedProtectedKey, err := crypto.DecryptSymmetric(derivedKey, protectedKey, protectedKeyTag, protectedKeyIV) + if err != nil { + util.HandleError(fmt.Errorf("unable to get decrypted protected key [err=%s]", err)) + } + + encryptedPrivateKey, err := base64.StdEncoding.DecodeString(loginTwoResponse.EncryptedPrivateKey) + if err != nil { + util.HandleError(err) + } + + decryptedProtectedKeyInHex, err := hex.DecodeString(string(decryptedProtectedKey)) + if err != nil { + util.HandleError(err) + } + + decryptedPrivateKey, err = crypto.DecryptSymmetric(decryptedProtectedKeyInHex, encryptedPrivateKey, nonProtectedTag, nonProtectedIv) + + if err != nil { + util.HandleError(err) + } + } else { + util.PrintErrorMessageAndExit("Insufficient details to decrypt private key") } userCredentialsToBeStored := &models.UserCredentials{ Email: email, PrivateKey: string(decryptedPrivateKey), - JTWToken: userCredentials.JTWToken, + JTWToken: loginTwoResponse.Token, } err = util.StoreUserCredsInKeyRing(userCredentialsToBeStored) @@ -155,7 +233,7 @@ func askForLoginCredentials() (email string, password string, err error) { return userEmail, userPassword, nil } -func getFreshUserCredentials(email string, password string) (*api.LoginTwoResponse, error) { +func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2Response, *api.GetLoginTwoV2Response, error) { log.Debugln("getFreshUserCredentials:", "email", email, "password", password) httpClient := resty.New() httpClient.SetRetryCount(5) @@ -166,36 +244,24 @@ func getFreshUserCredentials(email string, password string) (*api.LoginTwoRespon srpA := hex.EncodeToString(srpClient.ComputeA()) // ** Login one - loginOneRequest := api.LoginOneRequest{ + loginOneResponseResult, err := api.CallLogin1V2(httpClient, api.GetLoginOneV2Request{ Email: email, ClientPublicKey: srpA, - } - - var loginOneResponseResult api.LoginOneResponse - - loginOneResponse, err := httpClient. - R(). - SetBody(loginOneRequest). - SetResult(&loginOneResponseResult). - Post(fmt.Sprintf("%v/v1/auth/login1", config.INFISICAL_URL)) + }) if err != nil { - return nil, err - } - - if loginOneResponse.StatusCode() > 299 { - return nil, fmt.Errorf("ops, unsuccessful response code. [response=%v]", loginOneResponse) + util.HandleError(err) } // **** Login 2 serverPublicKey_bytearray, err := hex.DecodeString(loginOneResponseResult.ServerPublicKey) if err != nil { - return nil, err + return nil, nil, err } - userSalt, err := hex.DecodeString(loginOneResponseResult.ServerSalt) + userSalt, err := hex.DecodeString(loginOneResponseResult.Salt) if err != nil { - return nil, err + return nil, nil, err } srpClient.SetSalt(userSalt, []byte(email), []byte(password)) @@ -203,27 +269,16 @@ func getFreshUserCredentials(email string, password string) (*api.LoginTwoRespon srpM1 := srpClient.ComputeM1() - LoginTwoRequest := api.LoginTwoRequest{ + loginTwoResponseResult, err := api.CallLogin2V2(httpClient, api.GetLoginTwoV2Request{ Email: email, ClientProof: hex.EncodeToString(srpM1), - } - - var loginTwoResponseResult api.LoginTwoResponse - loginTwoResponse, err := httpClient. - R(). - SetBody(LoginTwoRequest). - SetResult(&loginTwoResponseResult). - Post(fmt.Sprintf("%v/v1/auth/login2", config.INFISICAL_URL)) + }) if err != nil { - return nil, err + util.HandleError(err) } - if loginTwoResponse.StatusCode() > 299 { - return nil, fmt.Errorf("ops, unsuccessful response code. [response=%v]", loginTwoResponse) - } - - return &loginTwoResponseResult, nil + return &loginOneResponseResult, &loginTwoResponseResult, nil } func shouldOverrideLoginPrompt(currentLoggedInUserEmail string) (bool, error) { From 1d11f11eafa159e48e10c49d7b2764718555654e Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 16 Feb 2023 15:49:14 -0800 Subject: [PATCH 10/11] Refactor login cmd --- cli/packages/cmd/login.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index c2d193d8a..e13756b54 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -33,12 +33,6 @@ type params struct { keyLength uint32 } -func generateFromPassword(password string, salt []byte, p *params) (hash []byte, err error) { - hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) - - return hash, nil -} - // loginCmd represents the login command var loginCmd = &cobra.Command{ Use: "login", @@ -151,7 +145,6 @@ var loginCmd = &cobra.Command{ } decryptedPrivateKey, err = crypto.DecryptSymmetric(decryptedProtectedKeyInHex, encryptedPrivateKey, nonProtectedTag, nonProtectedIv) - if err != nil { util.HandleError(err) } @@ -292,3 +285,8 @@ func shouldOverrideLoginPrompt(currentLoggedInUserEmail string) (bool, error) { } return result == "Yes", err } + +func generateFromPassword(password string, salt []byte, p *params) (hash []byte, err error) { + hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + return hash, nil +} From be38844a5b67f8642d5afb576f4ceac4837e6a2e Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Fri, 17 Feb 2023 20:48:19 -0500 Subject: [PATCH 11/11] Add 2FA in CLI --- cli/packages/api/api.go | 22 ++++++++++++++ cli/packages/api/model.go | 32 +++++++++++++++++++++ cli/packages/cmd/login.go | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 637fc1065..f0a347800 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -148,6 +148,28 @@ func CallLogin1V2(httpClient *resty.Client, request GetLoginOneV2Request) (GetLo return loginOneV2Response, nil } +func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) { + var verifyMfaTokenResponse VerifyMfaTokenResponse + var responseError VerifyMfaTokenErrorResponse + response, err := httpClient. + R(). + SetResult(&verifyMfaTokenResponse). + SetHeader("User-Agent", USER_AGENT). + SetError(&responseError). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/mfa/verify", config.INFISICAL_URL)) + + if err != nil { + return nil, nil, fmt.Errorf("CallVerifyMfaToken: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return nil, &responseError, nil + } + + return &verifyMfaTokenResponse, nil, nil +} + func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLoginTwoV2Response, error) { var loginTwoV2Response GetLoginTwoV2Response response, err := httpClient. diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 5dd20b1df..af8dbc5b4 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -291,3 +291,35 @@ type GetLoginTwoV2Response struct { ProtectedKeyIV string `json:"protectedKeyIV"` ProtectedKeyTag string `json:"protectedKeyTag"` } + +type VerifyMfaTokenRequest struct { + Email string `json:"email"` + MFAToken string `json:"mfaToken"` +} + +type VerifyMfaTokenResponse struct { + EncryptionVersion int `json:"encryptionVersion"` + Token string `json:"token"` + PublicKey string `json:"publicKey"` + EncryptedPrivateKey string `json:"encryptedPrivateKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + ProtectedKey string `json:"protectedKey"` + ProtectedKeyIV string `json:"protectedKeyIV"` + ProtectedKeyTag string `json:"protectedKeyTag"` +} + +type VerifyMfaTokenErrorResponse struct { + Type string `json:"type"` + Message string `json:"message"` + Context struct { + Code string `json:"code"` + TriesLeft int `json:"triesLeft"` + } `json:"context"` + Level int `json:"level"` + LevelName string `json:"level_name"` + StatusCode int `json:"status_code"` + DatetimeIso time.Time `json:"datetime_iso"` + Application string `json:"application"` + Extra []interface{} `json:"extra"` +} diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index e13756b54..e09253407 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -70,6 +70,53 @@ var loginCmd = &cobra.Command{ return } + if loginTwoResponse.MfaEnabled { + i := 1 + for i < 6 { + mfaVerifyCode := askForMFACode() + + httpClient := resty.New() + httpClient.SetAuthToken(loginTwoResponse.Token) + verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ + Email: email, + MFAToken: mfaVerifyCode, + }) + + if requestError != nil { + util.HandleError(err) + break + } else if mfaErrorResponse != nil { + if mfaErrorResponse.Context.Code == "mfa_invalid" { + msg := fmt.Sprintf("Incorrect, MFA code. You have %v attempts left", 5-i) + fmt.Println(msg) + if i == 5 { + util.PrintErrorMessageAndExit("No tries left, please try again in a bit") + break + } + } + + if mfaErrorResponse.Context.Code == "mfa_expired" { + util.PrintErrorMessageAndExit("Your MFA code has expired, please try logging in again") + break + } + i++ + } else { + loginTwoResponse.EncryptedPrivateKey = verifyMFAresponse.EncryptedPrivateKey + loginTwoResponse.EncryptionVersion = verifyMFAresponse.EncryptionVersion + loginTwoResponse.Iv = verifyMFAresponse.Iv + loginTwoResponse.ProtectedKey = verifyMFAresponse.ProtectedKey + loginTwoResponse.ProtectedKeyIV = verifyMFAresponse.ProtectedKeyIV + loginTwoResponse.ProtectedKeyTag = verifyMFAresponse.ProtectedKeyTag + loginTwoResponse.PublicKey = verifyMFAresponse.PublicKey + loginTwoResponse.Tag = verifyMFAresponse.Tag + loginTwoResponse.Token = verifyMFAresponse.Token + loginTwoResponse.EncryptionVersion = verifyMFAresponse.EncryptionVersion + + break + } + } + } + var decryptedPrivateKey []byte if loginTwoResponse.EncryptionVersion == 1 { @@ -290,3 +337,16 @@ func generateFromPassword(password string, salt []byte, p *params) (hash []byte, hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) return hash, nil } + +func askForMFACode() string { + mfaCodePromptUI := promptui.Prompt{ + Label: "MFA verification code", + } + + mfaVerifyCode, err := mfaCodePromptUI.Run() + if err != nil { + util.HandleError(err) + } + + return mfaVerifyCode +}