From 5cadb9e2f93d6d9507a0fa70934c4bb36edf033c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 23 Jan 2023 22:10:15 +0700 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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 868011479bc672893a25597f408c5a943833acf2 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Wed, 8 Feb 2023 10:39:59 +0100 Subject: [PATCH 06/49] feat(chart): mongodb persistence --- helm-charts/infisical/.gitignore | 1 + helm-charts/infisical/Chart.lock | 6 ++ helm-charts/infisical/Chart.yaml | 6 ++ helm-charts/infisical/templates/_helpers.tpl | 7 +- .../templates/mongodb-deployment.yaml | 49 ------------ helm-charts/infisical/values.yaml | 78 ++++++++++++------- 6 files changed, 68 insertions(+), 79 deletions(-) create mode 100644 helm-charts/infisical/.gitignore create mode 100644 helm-charts/infisical/Chart.lock delete mode 100644 helm-charts/infisical/templates/mongodb-deployment.yaml diff --git a/helm-charts/infisical/.gitignore b/helm-charts/infisical/.gitignore new file mode 100644 index 000000000..711a39c54 --- /dev/null +++ b/helm-charts/infisical/.gitignore @@ -0,0 +1 @@ +charts/ \ No newline at end of file diff --git a/helm-charts/infisical/Chart.lock b/helm-charts/infisical/Chart.lock new file mode 100644 index 000000000..633998bc2 --- /dev/null +++ b/helm-charts/infisical/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mongodb + repository: https://charts.bitnami.com/bitnami + version: 13.6.7 +digest: sha256:f3a15cf01e2df1fc410b635cd7af684222d16b76d7e6a4d112883dc32b092249 +generated: "2023-02-08T00:14:41.706253573+01:00" diff --git a/helm-charts/infisical/Chart.yaml b/helm-charts/infisical/Chart.yaml index fe65e3b11..54ee708fd 100644 --- a/helm-charts/infisical/Chart.yaml +++ b/helm-charts/infisical/Chart.yaml @@ -14,3 +14,9 @@ version: 0.1.13 # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. appVersion: "1.17.0" + +dependencies: + - name: mongodb + version: "~13.6.7" + repository: "https://charts.bitnami.com/bitnami" + condition: mongodb.enabled \ No newline at end of file diff --git a/helm-charts/infisical/templates/_helpers.tpl b/helm-charts/infisical/templates/_helpers.tpl index d0ceb0641..f10e44c56 100644 --- a/helm-charts/infisical/templates/_helpers.tpl +++ b/helm-charts/infisical/templates/_helpers.tpl @@ -118,9 +118,10 @@ Create the mongodb connection string. {{- define "infisical.mongodb.connectionString" -}} {{- $host := include "infisical.mongodb.fullname" . -}} {{- $port := 27017 -}} -{{- $user := "root" -}} -{{- $pass := "root" -}} -{{- $connectionString := printf "mongodb://%s:%s@%s:%d/" $user $pass $host $port -}} +{{- $user := first .Values.mongodb.auth.usernames | default "root" -}} +{{- $pass := first .Values.mongodb.auth.usernames | default "root" -}} +{{- $database := first .Values.mongodb.auth.databases | default "test" -}} +{{- $connectionString := printf "mongodb://%s:%s@%s:%d/%s" $user $pass $host $port $database -}} {{- if .Values.mongodbConnection.externalMongoDBConnectionString -}} {{- $connectionString = .Values.mongodbConnection.externalMongoDBConnectionString -}} {{- end -}} diff --git a/helm-charts/infisical/templates/mongodb-deployment.yaml b/helm-charts/infisical/templates/mongodb-deployment.yaml deleted file mode 100644 index c9a4b13a8..000000000 --- a/helm-charts/infisical/templates/mongodb-deployment.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "infisical.mongodb.fullname" . }} - labels: - {{- include "infisical.mongodb.labels" . | nindent 4 }} -spec: - replicas: 1 # Cannot be scaled. To scale, you must set up Stateful Set - selector: - matchLabels: - {{- include "infisical.mongodb.matchLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "infisical.mongodb.matchLabels" . | nindent 8 }} - {{- with .Values.mongodb.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - containers: - - name: {{ template "infisical.name" . }}-{{ .Values.mongodb.name }} - image: "{{ .Values.mongodb.image.repository }}:{{ .Values.mongodb.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.mongodb.image.pullPolicy }} - ports: - - containerPort: 27017 - env: - - name: MONGO_INITDB_ROOT_USERNAME - value: root - - name: MONGO_INITDB_ROOT_PASSWORD - value: root ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "infisical.mongodb.fullname" . }} - labels: - {{- include "infisical.mongodb.labels" . | nindent 4 }} - {{- with .Values.mongodb.service.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - selector: - {{- include "infisical.mongodb.matchLabels" . | nindent 8 }} - ports: - - protocol: TCP - port: 27017 - targetPort: 27017 # container port diff --git a/helm-charts/infisical/values.yaml b/helm-charts/infisical/values.yaml index 9ed7ef9dc..946bd92c4 100644 --- a/helm-charts/infisical/values.yaml +++ b/helm-charts/infisical/values.yaml @@ -7,6 +7,7 @@ nameOverride: "" frontend: name: frontend + fullnameOverride: "" podAnnotations: {} deploymentAnnotations: {} replicaCount: 2 @@ -14,7 +15,7 @@ frontend: repository: infisical/frontend pullPolicy: IfNotPresent tag: "latest" - # kubeSecretRef: some-kube-secret-name + kubeSecretRef: "" service: # type of the frontend service type: ClusterIP @@ -24,6 +25,7 @@ frontend: backend: name: backend + fullnameOverride: "" podAnnotations: {} deploymentAnnotations: {} replicaCount: 2 @@ -31,31 +33,56 @@ backend: repository: infisical/backend pullPolicy: IfNotPresent tag: "latest" - # kubeSecretRef: some-kube-secret-name + kubeSecretRef: "" service: annotations: {} mongodb: - name: mongodb + enabled: true + name: "mongodb" + fullnameOverride: "mongodb" + nameOverride: "mongodb" podAnnotations: {} + useStatefulSet: true + architecture: "standalone" image: repository: mongo pullPolicy: IfNotPresent - tag: "latest" + tag: "6.0" service: annotations: {} + auth: + enabled: true + usernames: + - "infisical" + passwords: + - "infisical" + databases: + - "infisical" + persistence: + enabled: true + existingClaim: "" + resourcePolicy: "keep" + accessModes: ["ReadWriteOnce"] + size: 8Gi + volumePermissions: + enabled: true + args: + - "--dbpath=/bitnami/mongodb" # By default the backend will be connected to a Mongo instance in the cluster. # However, it is recommended to add a managed document DB connection string because the DB instance in the cluster does not have persistence yet ( data will be deleted on next deploy). # Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ -mongodbConnection: {} - # externalMongoDBConnectionString: <> +mongodbConnection: + externalMongoDBConnectionString: "" + # externalMongoDBConnectionString: "mongodb://:@:/" ingress: enabled: true annotations: kubernetes.io/ingress.class: "nginx" - hostName: example.com # replace with your domain + # cert-manager.io/issuer: letsencrypt-nginx + hostName: infisical.local # replace with your domain frontend: path: / pathType: Prefix @@ -63,26 +90,23 @@ ingress: path: /api pathType: Prefix tls: [] - - -## Complete Ingress example -# ingress: -# enabled: true -# annotations: -# kubernetes.io/ingress.class: "nginx" -# cert-manager.io/issuer: letsencrypt-nginx -# hostName: k8.infisical.com -# frontend: -# path: / -# pathType: Prefix -# backend: -# path: /api -# pathType: Prefix -# tls: -# - secretName: letsencrypt-nginx -# hosts: -# - k8.infisical.com + # - secretName: letsencrypt-nginx + # hosts: + # - k8.infisical.com frontendEnvironmentVariables: {} -backendEnvironmentVariables: {} +backendEnvironmentVariables: + # MY_ENV_VAR: my-value + # Required keys for platform encryption/decryption ops. (128-bit hex value, 32-characters hex) + # e.g. 'hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom', 'openssl rand -hex 16' (from https://stackoverflow.com/a/34329057) + ENCRYPTION_KEY: MUST_REPLACE + # JWT (required secrets to sign JWT tokens) + JWT_SIGNUP_SECRET: MUST_REPLACE + JWT_REFRESH_SECRET: MUST_REPLACE + JWT_AUTH_SECRET: MUST_REPLACE + # Mail/SMTP (required to send emails) + SMTP_HOST: MUST_REPLACE + SMTP_NAME: MUST_REPLACE + SMTP_USERNAME: MUST_REPLACE + SMTP_PASSWORD: MUST_REPLACE \ No newline at end of file From ca07d1c50ed4eb5459d9d3541055e3ddb857b376 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Thu, 9 Feb 2023 10:12:21 +0100 Subject: [PATCH 07/49] fix(chart): helpers template auth.password typo --- helm-charts/infisical/templates/_helpers.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm-charts/infisical/templates/_helpers.tpl b/helm-charts/infisical/templates/_helpers.tpl index f10e44c56..bf3f8e301 100644 --- a/helm-charts/infisical/templates/_helpers.tpl +++ b/helm-charts/infisical/templates/_helpers.tpl @@ -119,7 +119,7 @@ Create the mongodb connection string. {{- $host := include "infisical.mongodb.fullname" . -}} {{- $port := 27017 -}} {{- $user := first .Values.mongodb.auth.usernames | default "root" -}} -{{- $pass := first .Values.mongodb.auth.usernames | default "root" -}} +{{- $pass := first .Values.mongodb.auth.passwords | default "root" -}} {{- $database := first .Values.mongodb.auth.databases | default "test" -}} {{- $connectionString := printf "mongodb://%s:%s@%s:%d/%s" $user $pass $host $port $database -}} {{- if .Values.mongodbConnection.externalMongoDBConnectionString -}} From cdf73043e13902dd628257609c5fb6cb26f0bc0f Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Tue, 14 Feb 2023 01:14:30 +0100 Subject: [PATCH 08/49] fix(chart): mongodb custom users + docs --- helm-charts/infisical/values.yaml | 395 ++++++++++++++++++++++++------ 1 file changed, 319 insertions(+), 76 deletions(-) diff --git a/helm-charts/infisical/values.yaml b/helm-charts/infisical/values.yaml index 946bd92c4..f670028b3 100644 --- a/helm-charts/infisical/values.yaml +++ b/helm-charts/infisical/values.yaml @@ -1,112 +1,355 @@ -##### -# INFISICAL K8 DEFAULT VALUES FILE -# PLEASE REPLACE VALUES/EDIT AS REQUIRED -##### +## @section Common parameters +## +## @param nameOverride Override release name +## nameOverride: "" +## @param fullnameOverride Override release fullname +## +fullnameOverride: "" + +## @section Infisical frontend parameters +## Documentation : https://infisical.com/docs/self-hosting/deployments/kubernetes +## frontend: + ## @param frontend.enabled Enable frontend + ## + enabled: true + ## @param frontend.name Backend name + ## name: frontend + ## @param frontend.fullnameOverride Backend fullnameOverride + ## fullnameOverride: "" + ## @param frontend.podAnnotations Backend pod annotations + ## podAnnotations: {} + ## @param frontend.deploymentAnnotations Backend deployment annotations + ## deploymentAnnotations: {} + ## @param frontend.replicaCount Backend replica count + ## replicaCount: 2 + ## Backend image parameters + ## image: + ## @param frontend.image.repository Backend image repository + ## repository: infisical/frontend - pullPolicy: IfNotPresent + ## @param frontend.image.tag Backend image tag + ## tag: "latest" + ## @param frontend.image.pullPolicy Backend image pullPolicy + ## + pullPolicy: IfNotPresent + ## @param frontend.kubeSecretRef Backend secret resource reference name (containing required [frontend configuration variables](https://infisical.com/docs/self-hosting/configuration/envars)) + ## kubeSecretRef: "" service: - # type of the frontend service - type: ClusterIP - # define the nodePort if service type is NodePort - # nodePort: + ## @param frontend.service.annotations Backend service annotations + ## annotations: {} + ## @param frontend.service.type Backend service type + ## + type: ClusterIP + ## @param frontend.service.nodePort Backend service nodePort (used if above type is `NodePort`) + ## + nodePort: "" + +## Frontend variables configuration +## Documentation : https://infisical.com/docs/self-hosting/configuration/envars +## +frontendEnvironmentVariables: + ## @param frontendEnvironmentVariables.SITE_URL Absolute URL including the protocol (e.g. https://app.infisical.com) + ## + SITE_URL: infisical.local + +## @section Infisical backend parameters +## Documentation : https://infisical.com/docs/self-hosting/deployments/kubernetes +## backend: + ## @param backend.enabled Enable backend + ## + enabled: true + ## @param backend.name Backend name + ## name: backend + ## @param backend.fullnameOverride Backend fullnameOverride + ## fullnameOverride: "" + ## @param backend.podAnnotations Backend pod annotations + ## podAnnotations: {} + ## @param backend.deploymentAnnotations Backend deployment annotations + ## deploymentAnnotations: {} + ## @param backend.replicaCount Backend replica count + ## replicaCount: 2 + ## Backend image parameters + ## image: + ## @param backend.image.repository Backend image repository + ## repository: infisical/backend - pullPolicy: IfNotPresent + ## @param backend.image.tag Backend image tag + ## tag: "latest" + ## @param backend.image.pullPolicy Backend image pullPolicy + ## + pullPolicy: IfNotPresent + ## @param backend.kubeSecretRef Backend secret resource reference name (containing required [backend configuration variables](https://infisical.com/docs/self-hosting/configuration/envars)) + ## kubeSecretRef: "" service: + ## @param backend.service.annotations Backend service annotations + ## annotations: {} + ## @param backend.service.type Backend service type + ## + type: ClusterIP + ## @param backend.service.nodePort Backend service nodePort (used if above type is `NodePort`) + ## + nodePort: "" -mongodb: - enabled: true - name: "mongodb" - fullnameOverride: "mongodb" - nameOverride: "mongodb" - podAnnotations: {} - useStatefulSet: true - architecture: "standalone" - image: - repository: mongo - pullPolicy: IfNotPresent - tag: "6.0" - service: - annotations: {} - auth: - enabled: true - usernames: - - "infisical" - passwords: - - "infisical" - databases: - - "infisical" - persistence: - enabled: true - existingClaim: "" - resourcePolicy: "keep" - accessModes: ["ReadWriteOnce"] - size: 8Gi - volumePermissions: - enabled: true - args: - - "--dbpath=/bitnami/mongodb" - -# By default the backend will be connected to a Mongo instance in the cluster. -# However, it is recommended to add a managed document DB connection string because the DB instance in the cluster does not have persistence yet ( data will be deleted on next deploy). -# Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ -mongodbConnection: - externalMongoDBConnectionString: "" - # externalMongoDBConnectionString: "mongodb://:@:/" - -ingress: - enabled: true - annotations: - kubernetes.io/ingress.class: "nginx" - # cert-manager.io/issuer: letsencrypt-nginx - hostName: infisical.local # replace with your domain - frontend: - path: / - pathType: Prefix - backend: - path: /api - pathType: Prefix - tls: [] - # - secretName: letsencrypt-nginx - # hosts: - # - k8.infisical.com - -frontendEnvironmentVariables: {} - +## Backend variables configuration +## Documentation : https://infisical.com/docs/self-hosting/configuration/envars +## backendEnvironmentVariables: - # MY_ENV_VAR: my-value - # Required keys for platform encryption/decryption ops. (128-bit hex value, 32-characters hex) - # e.g. 'hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom', 'openssl rand -hex 16' (from https://stackoverflow.com/a/34329057) + ## @param backendEnvironmentVariables.ENCRYPTION_KEY **Required** Backend encryption key (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) + ## Command to generate the required value (linux) : 'hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom', 'openssl rand -hex 16' + ## ENCRYPTION_KEY: MUST_REPLACE - # JWT (required secrets to sign JWT tokens) + ## @param backendEnvironmentVariables.JWT_SIGNUP_SECRET **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) + ## @param backendEnvironmentVariables.JWT_REFRESH_SECRET **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) + ## @param backendEnvironmentVariables.JWT_AUTH_SECRET **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) + ## @param backendEnvironmentVariables.JWT_SERVICE_SECRET **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) + ## Command to generate the required value (linux) : 'hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom', 'openssl rand -hex 16' + ## JWT_SIGNUP_SECRET: MUST_REPLACE JWT_REFRESH_SECRET: MUST_REPLACE JWT_AUTH_SECRET: MUST_REPLACE - # Mail/SMTP (required to send emails) + JWT_SERVICE_SECRET: MUST_REPLACE + ## @param backendEnvironmentVariables.SMTP_HOST **Required** Hostname to connect to for establishing SMTP connections + ## @param backendEnvironmentVariables.SMTP_PORT Port to connect to for establishing SMTP connections + ## @param backendEnvironmentVariables.SMTP_SECURE If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported + ## @param backendEnvironmentVariables.SMTP_FROM_NAME Name label to be used in From field (e.g. Infisical) + ## @param backendEnvironmentVariables.SMTP_FROM_ADDRESS **Required** Email address to be used for sending emails (e.g. dev@infisical.com) + ## @param backendEnvironmentVariables.SMTP_USERNAME **Required** Credential to connect to host (e.g. team@infisical.com) + ## @param backendEnvironmentVariables.SMTP_PASSWORD **Required** Credential to connect to host + ## SMTP_HOST: MUST_REPLACE - SMTP_NAME: MUST_REPLACE + SMTP_PORT: 587 + SMTP_SECURE: false + SMTP_FROM_NAME: Infisical + SMTP_FROM_ADDRESS: MUST_REPLACE SMTP_USERNAME: MUST_REPLACE - SMTP_PASSWORD: MUST_REPLACE \ No newline at end of file + SMTP_PASSWORD: MUST_REPLACE + ## @param backendEnvironmentVariables.SITE_URL Absolute URL including the protocol (e.g. https://app.infisical.com) + ## + SITE_URL: infisical.local + +## @section MongoDB(®) parameters +## Documentation : https://github.com/bitnami/charts/blob/main/bitnami/mongodb/values.yaml +## + +mongodb: + ## @param mongodb.enabled Enable MongoDB(®) + ## + enabled: true + ## @param mongodb.name Name used to build variables (deprecated) + ## + name: "mongodb" + ## @param mongodb.fullnameOverride Fullname override + ## + fullnameOverride: "mongodb" + ## @param mongodb.nameOverride Name override + ## + nameOverride: "mongodb" + ## @param mongodb.podAnnotations Pod annotations + ## + podAnnotations: {} + ## @param mongodb.useStatefulSet Set to true to use a StatefulSet instead of a Deployment (only when `architecture: standalone`) + ## + useStatefulSet: true + ## @param mongodb.architecture MongoDB(®) architecture (`standalone` or `replicaset`) + ## + architecture: "standalone" + ## Bitnami MongoDB(®) image + ## ref: https://hub.docker.com/r/bitnami/mongodb/tags/ + ## @param mongodb.image.repository MongoDB(®) image registry + ## @param mongodb.image.tag MongoDB(®) image tag (immutable tags are recommended) + ## @param mongodb.image.pullPolicy MongoDB(®) image pull policy + ## + image: + repository: bitnami/mongodb + pullPolicy: IfNotPresent + tag: "6.0.4-debian-11-r0" + ## @param mongodb.service.annotations Service annotations + ## + service: + annotations: {} + ## Infisical MongoDB custom authentication + ## + auth: + ## @param mongodb.auth.enabled Enable custom authentication + ## + enabled: true + ## @param mongodb.auth.usernames Custom usernames list ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) + ## + usernames: + - "infisical" + ## @param mongodb.auth.passwords Custom passwords list, match the above usernames order ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) + ## + passwords: + - "infisical" + ## @param mongodb.auth.databases Custom databases list ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) + ## + databases: + - "infisical" + ## MongoDB persistence configuration + ## + persistence: + ## @param mongodb.persistence.enabled Enable database persistence + ## + enabled: true + ## @param mongodb.persistence.existingClaim Existing persistent volume claim name + ## + existingClaim: "" + ## @param mongodb.persistence.resourcePolicy Keep the persistent volume even on deletion (`keep` or `""`) + ## + resourcePolicy: "keep" + ## @param mongodb.persistence.accessModes Persistent volume access modes + ## + accessModes: ["ReadWriteOnce"] + ## @param mongodb.persistence.size Persistent storage request size + ## + size: 8Gi + +## @param mongodbConnection.externalMongoDBConnectionString External MongoDB connection string +## By default the backend will be connected to a Mongo instance within the cluster +## However, it is recommended to add a managed document DB connection string for production-use (DBaaS) +## Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ +## e.g. "mongodb://:@:/" +## +mongodbConnection: + externalMongoDBConnectionString: "" + +## @section Ingress parameters +## + +ingress: + ## @param ingress.enabled Enable ingress + ## + enabled: true + annotations: + ## @skip ingress.annotations.kubernetes.io/ingress.class + ## + kubernetes.io/ingress.class: "nginx" + # cert-manager.io/issuer: letsencrypt-nginx + ## @param ingress.hostName Ingress hostname (your custom domain name) + ## Replace with your own domain + ## + hostName: infisical.local + ## @skip ingress.frontend + ## + frontend: + path: / + pathType: Prefix + ## @skip ingress.backend + ## + backend: + path: /api + pathType: Prefix + ## @param ingress.tls Ingress TLS hosts (matching above hostName) + ## Replace with your own domain + ## + tls: [] + # - secretName: letsencrypt-nginx + # hosts: + # - infisical.local + +## @section Mailhog parameters +## Documentation : https://github.com/codecentric/helm-charts/blob/master/charts/mailhog/values.yaml +## + +mailhog: + ## @param mailhog.enabled Enable Mailhog + ## + enabled: false + ## @param mailhog.fullnameOverride Fullname override + ## + fullnameOverride: "mailhog" + ## @param mailhog.nameOverride Name override + ## + nameOverride: "" + ## @param mailhog.image.repository Image repository + ## Why we use this version : https://github.com/mailhog/MailHog/issues/353#issuecomment-821137362 + ## @param mailhog.image.tag Image tag + ## @param mailhog.image.pullPolicy Image pull policy + ## + image: + repository: lytrax/mailhog + tag: "latest" + pullPolicy: IfNotPresent + + containerPort: + ## @param mailhog.containerPort.http.port Mailhog HTTP port (Web UI) + ## @skip mailhog.containerPort.http.name + ## + http: + name: http + port: 8025 + ## @param mailhog.containerPort.smtp.port Mailhog SMTP port (Mail) + ## @skip mailhog.containerPort.smtp.name + ## + smtp: + name: tcp-smtp + port: 1025 + ## @skip mailhog.service + ## + service: + annotations: {} + extraPorts: [] + clusterIP: "" + externalIPs: [] + loadBalancerIP: "" + loadBalancerSourceRanges: [] + type: ClusterIP + # Named target ports are not supported by GCE health checks, so when deploying on GKE + # and exposing it via GCE ingress, the health checks fail and the load balancer returns a 502. + namedTargetPort: true + port: + http: 8025 + smtp: 1025 + nodePort: + http: "" + smtp: "" + ## Mailhog ingress + ## + ingress: + ## @param mailhog.ingress.enabled Enable ingress + ## + enabled: true + ## @param mailhog.ingress.ingressClassName Ingress class name + ## + ingressClassName: nginx + ## @param mailhog.ingress.annotations Ingress annotations + ## + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + ## @param mailhog.ingress.labels Ingress labels + ## + labels: {} + hosts: + ## @param mailhog.ingress.hosts[0].host Mailhog host + ## + - host: mailhog.infisical.local + ## @skip mailhog.ingress.hosts[0].paths + ## + paths: + - path: "/" + pathType: Prefix \ No newline at end of file From 4df82a6ff191a14c660d6c8a93ea9f8fff1cca94 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Tue, 14 Feb 2023 01:15:27 +0100 Subject: [PATCH 09/49] feat(chart): mailhog for local development --- helm-charts/infisical/Chart.lock | 7 +++++-- helm-charts/infisical/Chart.yaml | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/helm-charts/infisical/Chart.lock b/helm-charts/infisical/Chart.lock index 633998bc2..3b5f48ca4 100644 --- a/helm-charts/infisical/Chart.lock +++ b/helm-charts/infisical/Chart.lock @@ -2,5 +2,8 @@ dependencies: - name: mongodb repository: https://charts.bitnami.com/bitnami version: 13.6.7 -digest: sha256:f3a15cf01e2df1fc410b635cd7af684222d16b76d7e6a4d112883dc32b092249 -generated: "2023-02-08T00:14:41.706253573+01:00" +- name: mailhog + repository: https://codecentric.github.io/helm-charts + version: 5.2.3 +digest: sha256:a54ae9ee60775f6f1aa916b59aee55b3ed5234b6bd88185fcb118b7f69539d70 +generated: "2023-02-13T14:13:27.525541038+01:00" diff --git a/helm-charts/infisical/Chart.yaml b/helm-charts/infisical/Chart.yaml index 54ee708fd..d0d293b71 100644 --- a/helm-charts/infisical/Chart.yaml +++ b/helm-charts/infisical/Chart.yaml @@ -7,7 +7,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.13 +version: 0.1.14 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to @@ -19,4 +19,8 @@ dependencies: - name: mongodb version: "~13.6.7" repository: "https://charts.bitnami.com/bitnami" - condition: mongodb.enabled \ No newline at end of file + condition: mongodb.enabled + - name: mailhog + version: "~5.2.3" + repository: "https://codecentric.github.io/helm-charts" + condition: mailhog.enabled \ No newline at end of file From 2db4a29ad7e2e625fc89d474c90d367e2415b2a8 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Tue, 14 Feb 2023 01:17:19 +0100 Subject: [PATCH 10/49] chore(docs): chart setup + chart release notes.txt --- helm-charts/README.md | 42 +++-- helm-charts/infisical/README.md | 187 ++++++++++++++++++++++ helm-charts/infisical/templates/NOTES.txt | 80 +++++++++ 3 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 helm-charts/infisical/README.md diff --git a/helm-charts/README.md b/helm-charts/README.md index 468275e3f..b60f40c35 100644 --- a/helm-charts/README.md +++ b/helm-charts/README.md @@ -1,17 +1,35 @@ -### helm repository Setup -Assuming you have helm already installed, it is straight-forward to add a Cloudsmith-based chart repository: +# Infisical Helm Charts -``` -helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +Welcome to Infisical Helm Charts repository! Find below the instrcutions to setup and install our charts. + +```sh +# Add the Infisical repository +helm repo add infisical 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' && helm repo update + +# Install Infisical +helm upgrade --install --atomic \ + -n infisical-dev --create-namespace \ + infisical infisical/infisical -helm repo update +# Install Infisical Secrets Operator +helm upgrade --install --atomic \ + -n infisical-dev --create-namespace \ + infisical-secrets-operator infisical/secrets-operator ``` -### Installing a Helm Chart -``` -helm install infisical-helm-charts/ -``` +## Charts -#### Available chart names -- infisical -- secrets-operator +Here's the link to our charts corresponding documentation : +- **`[infisical](./infisical/README.md)`** +- **`secrets-operator`** + +## Documentation + +We're trying to follow a documentation convention across our charts, allowing us to auto-generate markdown documentation thanks to [this tool](https://github.com/bitnami-labs/readme-generator-for-helm) + +Steps to update the documentation : +1. `git clone https://github.com/bitnami-labs/readme-generator-for-helm` +2. `npm install ./readme-generator-for-helm` +3. `npm exec readme-generator -- --readme /README.md --values /values.yaml` + - It'll insert the table below the `## Parameters` title + - It'll output errors if some of the path aren't documented \ No newline at end of file diff --git a/helm-charts/infisical/README.md b/helm-charts/infisical/README.md new file mode 100644 index 000000000..6965b818a --- /dev/null +++ b/helm-charts/infisical/README.md @@ -0,0 +1,187 @@ +# Infisical - Helm Chart + +This is the Infisical application Helm chart. + +## Parameters + +### Common parameters + +| Name | Description | Value | +| ------------------ | ------------------------- | ----- | +| `nameOverride` | Override release name | `""` | +| `fullnameOverride` | Override release fullname | `""` | + + +### Infisical frontend parameters + +| Name | Description | Value | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| `frontend.enabled` | Enable frontend | `true` | +| `frontend.name` | Backend name | `frontend` | +| `frontend.fullnameOverride` | Backend fullnameOverride | `""` | +| `frontend.podAnnotations` | Backend pod annotations | `{}` | +| `frontend.deploymentAnnotations` | Backend deployment annotations | `{}` | +| `frontend.replicaCount` | Backend replica count | `2` | +| `frontend.image.repository` | Backend image repository | `infisical/frontend` | +| `frontend.image.tag` | Backend image tag | `latest` | +| `frontend.image.pullPolicy` | Backend image pullPolicy | `IfNotPresent` | +| `frontend.kubeSecretRef` | Backend secret resource reference name (containing required [frontend configuration variables](https://infisical.com/docs/self-hosting/configuration/envars)) | `""` | +| `frontend.service.annotations` | Backend service annotations | `{}` | +| `frontend.service.type` | Backend service type | `ClusterIP` | +| `frontend.service.nodePort` | Backend service nodePort (used if above type is `NodePort`) | `""` | +| `frontendEnvironmentVariables.SITE_URL` | Absolute URL including the protocol (e.g. https://app.infisical.com) | `infisical.local` | + + +### Infisical backend parameters + +| Name | Description | Value | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | +| `backend.enabled` | Enable backend | `true` | +| `backend.name` | Backend name | `backend` | +| `backend.fullnameOverride` | Backend fullnameOverride | `""` | +| `backend.podAnnotations` | Backend pod annotations | `{}` | +| `backend.deploymentAnnotations` | Backend deployment annotations | `{}` | +| `backend.replicaCount` | Backend replica count | `2` | +| `backend.image.repository` | Backend image repository | `infisical/backend` | +| `backend.image.tag` | Backend image tag | `latest` | +| `backend.image.pullPolicy` | Backend image pullPolicy | `IfNotPresent` | +| `backend.kubeSecretRef` | Backend secret resource reference name (containing required [backend configuration variables](https://infisical.com/docs/self-hosting/configuration/envars)) | `""` | +| `backend.service.annotations` | Backend service annotations | `{}` | +| `backend.service.type` | Backend service type | `ClusterIP` | +| `backend.service.nodePort` | Backend service nodePort (used if above type is `NodePort`) | `""` | +| `backendEnvironmentVariables.ENCRYPTION_KEY` | **Required** Backend encryption key (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) | `MUST_REPLACE` | +| `backendEnvironmentVariables.JWT_SIGNUP_SECRET` | **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) | `MUST_REPLACE` | +| `backendEnvironmentVariables.JWT_REFRESH_SECRET` | **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) | `MUST_REPLACE` | +| `backendEnvironmentVariables.JWT_AUTH_SECRET` | **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) | `MUST_REPLACE` | +| `backendEnvironmentVariables.JWT_SERVICE_SECRET` | **Required** Secrets to sign JWT tokens (128-bit hex value, 32-characters hex, [example](https://stackoverflow.com/a/34329057)) | `MUST_REPLACE` | +| `backendEnvironmentVariables.SMTP_HOST` | **Required** Hostname to connect to for establishing SMTP connections | `MUST_REPLACE` | +| `backendEnvironmentVariables.SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` | +| `backendEnvironmentVariables.SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` | +| `backendEnvironmentVariables.SMTP_FROM_NAME` | Name label to be used in From field (e.g. Infisical) | `Infisical` | +| `backendEnvironmentVariables.SMTP_FROM_ADDRESS` | **Required** Email address to be used for sending emails (e.g. dev@infisical.com) | `MUST_REPLACE` | +| `backendEnvironmentVariables.SMTP_USERNAME` | **Required** Credential to connect to host (e.g. team@infisical.com) | `MUST_REPLACE` | +| `backendEnvironmentVariables.SMTP_PASSWORD` | **Required** Credential to connect to host | `MUST_REPLACE` | +| `backendEnvironmentVariables.SITE_URL` | Absolute URL including the protocol (e.g. https://app.infisical.com) | `infisical.local` | + + +### MongoDB(®) parameters + +| Name | Description | Value | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| `mongodb.enabled` | Enable MongoDB(®) | `true` | +| `mongodb.name` | Name used to build variables (deprecated) | `mongodb` | +| `mongodb.fullnameOverride` | Fullname override | `mongodb` | +| `mongodb.nameOverride` | Name override | `mongodb` | +| `mongodb.podAnnotations` | Pod annotations | `{}` | +| `mongodb.useStatefulSet` | Set to true to use a StatefulSet instead of a Deployment (only when `architecture: standalone`) | `true` | +| `mongodb.architecture` | MongoDB(®) architecture (`standalone` or `replicaset`) | `standalone` | +| `mongodb.image.repository` | MongoDB(®) image registry | `bitnami/mongodb` | +| `mongodb.image.tag` | MongoDB(®) image tag (immutable tags are recommended) | `6.0.4-debian-11-r0` | +| `mongodb.image.pullPolicy` | MongoDB(®) image pull policy | `IfNotPresent` | +| `mongodb.service.annotations` | Service annotations | `{}` | +| `mongodb.auth.enabled` | Enable custom authentication | `true` | +| `mongodb.auth.usernames` | Custom usernames list ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) | `["infisical"]` | +| `mongodb.auth.passwords` | Custom passwords list, match the above usernames order ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) | `["infisical"]` | +| `mongodb.auth.databases` | Custom databases list ([special characters warning](https://www.mongodb.com/docs/manual/reference/connection-string/#standard-connection-string-format)) | `["infisical"]` | +| `mongodb.persistence.enabled` | Enable database persistence | `true` | +| `mongodb.persistence.existingClaim` | Existing persistent volume claim name | `""` | +| `mongodb.persistence.resourcePolicy` | Keep the persistent volume even on deletion (`keep` or `""`) | `keep` | +| `mongodb.persistence.accessModes` | Persistent volume access modes | `["ReadWriteOnce"]` | +| `mongodb.persistence.size` | Persistent storage request size | `8Gi` | +| `mongodbConnection.externalMongoDBConnectionString` | External MongoDB connection string | `""` | + + +### Ingress parameters + +| Name | Description | Value | +| ------------------ | ------------------------------------------- | ----------------- | +| `ingress.enabled` | Enable ingress | `true` | +| `ingress.hostName` | Ingress hostname (your custom domain name) | `infisical.local` | +| `ingress.tls` | Ingress TLS hosts (matching above hostName) | `[]` | + + +### Mailhog parameters + +| Name | Description | Value | +| ---------------------------------- | -------------------------- | ------------------------- | +| `mailhog.enabled` | Enable Mailhog | `false` | +| `mailhog.fullnameOverride` | Fullname override | `mailhog` | +| `mailhog.nameOverride` | Name override | `""` | +| `mailhog.image.repository` | Image repository | `lytrax/mailhog` | +| `mailhog.image.tag` | Image tag | `latest` | +| `mailhog.image.pullPolicy` | Image pull policy | `IfNotPresent` | +| `mailhog.containerPort.http.port` | Mailhog HTTP port (Web UI) | `8025` | +| `mailhog.containerPort.smtp.port` | Mailhog SMTP port (Mail) | `1025` | +| `mailhog.ingress.enabled` | Enable ingress | `true` | +| `mailhog.ingress.ingressClassName` | Ingress class name | `nginx` | +| `mailhog.ingress.annotations` | Ingress annotations | `{}` | +| `mailhog.ingress.labels` | Ingress labels | `{}` | +| `mailhog.ingress.hosts[0].host` | Mailhog host | `mailhog.infisical.local` | + + +## Persistence + +The database persistence is enabled by default, your volumes will remain on your cluster even after uninstalling the chart. To disable persistence, set this value `mongodb.persistence.enabled: false` + +## Local development + +Use below values if you want to setup a local development environment, and adapt those variables as you need. Below example will deploy the following : +- https://infisical.local + - Your local Infisical instance + - You may have to add `infisical.local` to your `/etc/hosts` or similar depending your OS +- https://mailhog.infisical.local + - Local SMTP server used to receive the signup verification code + - You may have to add `mailhog.infisical.local` to your `/etc/hosts` or similar depending your OS + +```yaml +# values.dev.yaml + +# Enable all services for local development +frontend: + enabled: true +backend: + enabled: true +mongodb: + enabled: true +mailhog: + enabled: true + +# Configure backend development variables +backendEnvironmentVariables: + ENCRYPTION_KEY: 6c1fe4e407b8911c104518103505b218 + JWT_AUTH_SECRET: 4be6ba5602e0fa0ac6ac05c3cd4d247f + JWT_REFRESH_SECRET: 5f2f3c8f0159068dc2bbb3a652a716ff + JWT_SERVICE_SECRET: f32f716d70a42c5703f4656015e76200 + JWT_SIGNUP_SECRET: 3679e04ca949f914c03332aaaeba805a + SITE_URL: https://infisical.local + SMTP_FROM_ADDRESS: dev@infisical.local + SMTP_FROM_NAME: Local Infisical + SMTP_HOST: mailhog + SMTP_PASSWORD: "" + SMTP_PORT: 1025 + SMTP_SECURE: false + SMTP_USERNAME: dev@infisical.local + +# Configure frontend development variables +frontendEnvironmentVariables: + SITE_URL: https://infisical.local +``` + +After creating the above file, run : + +```sh +# Fetch the required charts +helm dep update + +# Install/upgrade Infisical +helm upgrade --install --atomic \ + -n infisical-dev --create-namespace \ + -f ./values.dev.yaml \ + infisical-dev . +``` + +## Upgrading + +### 1.15.0 + +Refactoring in progress, instructions are coming soon \ No newline at end of file diff --git a/helm-charts/infisical/templates/NOTES.txt b/helm-charts/infisical/templates/NOTES.txt index e69de29bb..c8ec2ae9f 100644 --- a/helm-charts/infisical/templates/NOTES.txt +++ b/helm-charts/infisical/templates/NOTES.txt @@ -0,0 +1,80 @@ +## + +-- Infisical Helm Chart -- + + __ __ + ( _) ( _) + / / \\ / /\_\_ + / / \\ / / | \ \ + / / \\ / / |\ \ \ + / / , \ , / / /| \ \ + / / |\_ /| / / / \ \_\ + / / |\/ _ '_|\ / / / \ \\ + | / |/ 0 \0\\ / | | \ \\ + | |\| \_\_ / / | \ \\ + | | |/ \.\ o\o) / \ | \\ + \ | /\\`v-v / | | \\ + | \/ /_| \\_| / | | \ \\ + | | /__/_ / _____ | | \ \\ + \| [__] \_/ |_________ \ | \ () + / [___] ( \ \ |\ | | // + | [___] |\| \| / |/ + /| [____] \ |/\ / / || + ( \ [____ / ) _\ \ \ \| | || + \ \ [_____| / / __/ \ / / // + | \ [_____/ / / \ | \/ // + | / '----| /=\____ _/ | / // + __ / / | / ___/ _/\ \ | || + (/-(/-\) / \ (/\/\)/ | / | / + (/\/\) / / // + _________/ / / + \____________/ ( + +██╗███╗ ██╗███████╗██╗███████╗██╗ ██████╗ █████╗ ██╗ +██║████╗ ██║██╔════╝██║██╔════╝██║██╔════╝██╔══██╗██║ +██║██╔██╗ ██║█████╗ ██║███████╗██║██║ ███████║██║ +██║██║╚██╗██║██╔══╝ ██║╚════██║██║██║ ██╔══██║██║ +██║██║ ╚████║██║ ██║███████║██║╚██████╗██║ ██║███████╗ +╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ +{{ .Chart.Name }} ({{ .Chart.Version }}) + + +╭―― Thank you for installing Infisical! 👋 ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――┤ +│ +│ Infisical / All-in-one open-source SecretOps solution to manage your secrets across your infra! 🔒🔑 +│ +│ Visit < https://infisical.com/docs > for further documentation about self-hosting! +│ +│ Current installation (infisical) : +│ • infisical-frontend : {{ .Values.frontend.enabled }} +│ • infisical-backend : {{ .Values.backend.enabled }} +│ • mongodb : {{ .Values.mongodb.enabled }} +│ • mailhog : {{ .Values.mailhog.enabled }} +│ +╰―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――┤ + +╭―― Here's a list of helpfull commands to get you started 📝 ―――――――――――――――――――――――――――――――――――――――――┤ +│ +│ → Get all the Infisical resources (excluding secrets/pvcs) +│ $ kubectl get all -n {{ .Release.Namespace }} +│ +│ → Get your release status +│ $ helm status {{ .Release.Namespace }} {{ .Release.Name }} +│ +│ → Get your release resources +│ $ helm get all {{ .Release.Namespace }} {{ .Release.Name }} +│ +│ → Uninstall your release +│ $ helm uninstall {{ .Release.Namespace }} {{ .Release.Name }} +│ +│ → Get MongoDB root password +│ $ kubectl get secret {{ .Release.Namespace }} mongodb +│ -o jsonpath="{.data['mongodb-root-password']}" | base64 -d +│ +│ → Get MongoDB users passwords +│ $ kubectl get secret {{ .Release.Namespace }} mongodb +│ -o jsonpath="{.data['mongodb-passwords']}" | base64 -d +│ +╰―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――┤ + +## \ No newline at end of file From d3fcb69c50989a9d20ebbae81a233f339a9bf209 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Tue, 14 Feb 2023 01:17:57 +0100 Subject: [PATCH 11/49] fix(chart): backend service missing configuration --- helm-charts/infisical/templates/backend-deployment.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helm-charts/infisical/templates/backend-deployment.yaml b/helm-charts/infisical/templates/backend-deployment.yaml index 7a366d390..4183641a6 100644 --- a/helm-charts/infisical/templates/backend-deployment.yaml +++ b/helm-charts/infisical/templates/backend-deployment.yaml @@ -64,9 +64,13 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: + type: {{ .Values.backend.service.type }} selector: {{- include "infisical.backend.matchLabels" . | nindent 8 }} ports: - protocol: TCP port: 4000 targetPort: 4000 # container port + {{- if eq .Values.frontend.service.type "NodePort" }} + nodePort: {{ .Values.frontend.service.nodePort }} + {{- end }} From 56ca6039bac8a392711ab765556c432e93b485d3 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Tue, 14 Feb 2023 10:50:34 +0100 Subject: [PATCH 12/49] fix(chart): backend service typos --- helm-charts/infisical/templates/backend-deployment.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm-charts/infisical/templates/backend-deployment.yaml b/helm-charts/infisical/templates/backend-deployment.yaml index 4183641a6..f93f3c87e 100644 --- a/helm-charts/infisical/templates/backend-deployment.yaml +++ b/helm-charts/infisical/templates/backend-deployment.yaml @@ -71,6 +71,6 @@ spec: - protocol: TCP port: 4000 targetPort: 4000 # container port - {{- if eq .Values.frontend.service.type "NodePort" }} - nodePort: {{ .Values.frontend.service.nodePort }} + {{- if eq .Values.backend.service.type "NodePort" }} + nodePort: {{ .Values.backend.service.nodePort }} {{- end }} From e1ad8fbee8a3aca5325bc20a6f83224e0816260e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 14 Feb 2023 17:38:58 +0700 Subject: [PATCH 13/49] 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 14/49] 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 f28a2ea151f855de3f0c00985a250aa9c76e83b2 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 14 Feb 2023 18:40:02 -0800 Subject: [PATCH 15/49] Small nits --- helm-charts/README.md | 2 +- helm-charts/infisical/templates/NOTES.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/helm-charts/README.md b/helm-charts/README.md index b60f40c35..8449243de 100644 --- a/helm-charts/README.md +++ b/helm-charts/README.md @@ -1,6 +1,6 @@ # Infisical Helm Charts -Welcome to Infisical Helm Charts repository! Find below the instrcutions to setup and install our charts. +Welcome to Infisical Helm Charts repository! Find instructions below to setup and install our charts. ```sh # Add the Infisical repository diff --git a/helm-charts/infisical/templates/NOTES.txt b/helm-charts/infisical/templates/NOTES.txt index c8ec2ae9f..027ecde67 100644 --- a/helm-charts/infisical/templates/NOTES.txt +++ b/helm-charts/infisical/templates/NOTES.txt @@ -43,7 +43,7 @@ │ │ Infisical / All-in-one open-source SecretOps solution to manage your secrets across your infra! 🔒🔑 │ -│ Visit < https://infisical.com/docs > for further documentation about self-hosting! +│ Visit < https://infisical.com/docs/self-hosting/overview > for further documentation about self-hosting! │ │ Current installation (infisical) : │ • infisical-frontend : {{ .Values.frontend.enabled }} From 754ea09400d9ebef218f06c863b676ac956cf289 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 15 Feb 2023 10:28:23 +0700 Subject: [PATCH 16/49] 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 b5d4cfed03a5276dbc58e8b1621a364db30c4564 Mon Sep 17 00:00:00 2001 From: Grraahaam <72856427+Grraahaam@users.noreply.github.com> Date: Wed, 15 Feb 2023 09:43:17 +0100 Subject: [PATCH 17/49] chore(docs): chart documentation generation --- helm-charts/README.md | 3 ++- helm-charts/infisical/.gitignore | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/helm-charts/README.md b/helm-charts/README.md index 8449243de..01675d0b2 100644 --- a/helm-charts/README.md +++ b/helm-charts/README.md @@ -28,8 +28,9 @@ Here's the link to our charts corresponding documentation : We're trying to follow a documentation convention across our charts, allowing us to auto-generate markdown documentation thanks to [this tool](https://github.com/bitnami-labs/readme-generator-for-helm) Steps to update the documentation : +1. `cd helm-charts/` 1. `git clone https://github.com/bitnami-labs/readme-generator-for-helm` 2. `npm install ./readme-generator-for-helm` -3. `npm exec readme-generator -- --readme /README.md --values /values.yaml` +3. `npm exec readme-generator -- --readme README.md --values values.yaml` - It'll insert the table below the `## Parameters` title - It'll output errors if some of the path aren't documented \ No newline at end of file diff --git a/helm-charts/infisical/.gitignore b/helm-charts/infisical/.gitignore index 711a39c54..a2968aad7 100644 --- a/helm-charts/infisical/.gitignore +++ b/helm-charts/infisical/.gitignore @@ -1 +1,3 @@ -charts/ \ No newline at end of file +charts/ +node_modules/ +package*.json \ No newline at end of file From 65bec23292d98cbebc26ed08d9fd537d57f6a32c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 16 Feb 2023 22:43:53 +0700 Subject: [PATCH 18/49] Begin rewiring frontend to use batch route for CRUD secret ops --- .../src/controllers/v2/secretsController.ts | 251 +++++++++++++++++- .../src/ee/controllers/v1/secretController.ts | 8 +- backend/src/ee/models/secretVersion.ts | 11 +- backend/src/routes/v2/secrets.ts | 18 ++ backend/src/types/secret/index.d.ts | 38 +++ frontend/src/pages/api/files/batchSecrets.ts | 38 +++ frontend/src/pages/dashboard/[id].tsx | 77 +++++- 7 files changed, 413 insertions(+), 28 deletions(-) create mode 100644 frontend/src/pages/api/files/batchSecrets.ts diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 9a011bc5f..8f516b2dd 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -1,7 +1,8 @@ import to from 'await-to-js'; import { Types } from 'mongoose'; import { Request, Response } from 'express'; -import { ISecret, Membership, Secret, Workspace } from '../../models'; +import { ISecret, Secret } from '../../models'; +import { IAction } from '../../ee/models'; import { SECRET_PERSONAL, SECRET_SHARED, @@ -20,6 +21,250 @@ import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization'; import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; import Tag from '../../models/tag'; import _ from 'lodash'; +import { + BatchSecretRequest, + BatchSecret +} from '../../types/secret'; + +/** + * Peform a batch of any specified CUD secret operations + * @param req + * @param res + */ +export const batchSecrets = async (req: Request, res: Response) => { + const channel = getChannelFromUserAgent(req.headers['user-agent']); + const { + workspaceId, + environment, + requests + }: { + workspaceId: string; + environment: string; + requests: BatchSecretRequest[]; + }= req.body; + + // construct object containing all secrets + // listed across requests + const listedSecretsObj: { + [key: string]: { + version: number; + type: string; + } + } = (await Secret.find({ + _id: { + $in: requests + .map((request) => request.secret._id) + .filter((secretId) => secretId !== undefined) + } + }).select('version type')).reduce((obj: any, secret: ISecret) => ({ + ...obj, + [secret._id.toString()]: secret + }), {}); + + + const createSecrets: BatchSecret[] = []; + const updateSecrets: BatchSecret[] = []; + const deleteSecrets: Types.ObjectId[] = []; + const actions: IAction[] = []; + + requests.forEach((request) => { + switch (request.method) { + case 'POST': + createSecrets.push({ + ...request.secret, + version: 1, + user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, + environment, + workspace: new Types.ObjectId(workspaceId) + }); + break; + case 'PATCH': + updateSecrets.push({ + ...request.secret, + _id: new Types.ObjectId(request.secret._id) + }); + break; + case 'DELETE': + deleteSecrets.push(new Types.ObjectId(request.secret._id)); + break; + } + }); + + // handle create secrets + let createdSecrets: ISecret[] = []; + if (createSecrets.length > 0) { + createdSecrets = await Secret.insertMany(createSecrets); + // (EE) add secret versions for new secrets + await EESecretService.addSecretVersions({ + secretVersions: createdSecrets.map((n: any) => { + return ({ + ...n._doc, + _id: new Types.ObjectId(), + secret: n._id, + isDeleted: false + }); + }) + }); + + const addAction = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: createdSecrets.map((n) => n._id) + }) as IAction; + actions.push(addAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: createdSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle update secrets + let updatedSecrets: ISecret[] = []; + if (updateSecrets.length > 0) { + const updateOperations = updateSecrets.map((u) => ({ + updateOne: { + filter: { _id: new Types.ObjectId(u._id) }, + update: { + $inc: { + version: 1 + }, + ...u, + _id: new Types.ObjectId(u._id) + } + } + })); + + await Secret.bulkWrite(updateOperations); + + const secretVersions = updateSecrets.map((u) => ({ + secret: new Types.ObjectId(u._id), + version: listedSecretsObj[u._id.toString()].version, + workspace: new Types.ObjectId(workspaceId), + type: listedSecretsObj[u._id.toString()].type, + environment, + isDeleted: false, + secretKeyCiphertext: u.secretKeyCiphertext, + secretKeyIV: u.secretKeyIV, + secretKeyTag: u.secretKeyTag, + secretValueCiphertext: u.secretValueCiphertext, + secretValueIV: u.secretValueIV, + secretValueTag: u.secretValueTag, + secretCommentCiphertext: u.secretCommentCiphertext, + secretCommentIV: u.secretCommentIV, + secretCommentTag: u.secretCommentTag, + tags: u.tags + })); + + await EESecretService.addSecretVersions({ + secretVersions + }); + + updatedSecrets = await Secret.find({ + _id: { + $in: updateSecrets.map((u) => new Types.ObjectId(u._id)) + } + }); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: updateSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle delete secrets + if (deleteSecrets.length > 0) { + await Secret.deleteMany({ + _id: { + $in: deleteSecrets + } + }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deleteSecrets + }); + + const deleteAction = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: deleteSecrets + }) as IAction; + actions.push(deleteAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: deleteSecrets.length, + environment, + workspaceId, + channel: channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + if (actions.length > 1) { + // (EE) create (audit) log + await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: new Types.ObjectId(workspaceId), + actions, + channel, + ipAddress: req.ip + }); + } + + // // trigger event - push secrets + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const resObj: { [key: string]: ISecret[] | string[] } = {} + + if (createSecrets.length > 0) { + resObj['createdSecrets'] = createdSecrets; + } + + if (updateSecrets.length > 0) { + resObj['updatedSecrets'] = updatedSecrets; + } + + if (deleteSecrets.length > 0) { + resObj['deletedSecrets'] = deleteSecrets.map((d) => d.toString()); + } + + return res.status(200).send(resObj); +} /** * Create secret(s) for workspace with id [workspaceId] and environment [environment] @@ -166,11 +411,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, @@ -187,11 +430,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 562c8aa88..e1aca670f 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -158,11 +158,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash } = oldSecretVersion; // update secret @@ -179,11 +177,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash }, { new: true @@ -204,11 +200,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, - secretValueTag, - secretValueHash + secretValueTag }).save(); // take secret snapshot diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index efa042765..3095b52fb 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -5,22 +5,19 @@ import { } from '../../variables'; export interface ISecretVersion { - _id: Types.ObjectId; secret: Types.ObjectId; version: number; workspace: Types.ObjectId; // new type: string; // new - user: Types.ObjectId; // new + user?: Types.ObjectId; // new environment: string; // new isDeleted: boolean; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; - secretKeyHash: string; secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; - secretValueHash: string; tags?: string[]; } @@ -72,9 +69,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretKeyHash: { - type: String - }, secretValueCiphertext: { type: String, required: true @@ -87,9 +81,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretValueHash: { - type: String - }, tags: { ref: 'Tag', type: [Schema.Types.ObjectId], diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 9c5577d2f..81a288283 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -15,6 +15,24 @@ import { SECRET_SHARED } from '../../variables'; +// TODO: create batch update endpoint + +router.post( + '/batch', + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('requests').exists(), // perform validation for batch requests + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + secretsController.batchSecrets +) + router.post( '/', body('workspaceId').exists().isString().trim(), diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index 177df8c0f..257038f60 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -1,5 +1,7 @@ +import { Types } from 'mongoose'; import { Assign, Omit } from 'utility-types'; import { ISecret } from '../../models'; +import { mongo } from 'mongoose'; // Everything is required, except the omitted types export type CreateSecretRequestBody = Omit; @@ -12,3 +14,39 @@ export type SanitizedSecretModify = Partial; + +export interface BatchSecretRequest { + id: string; + method: 'POST' | 'PATCH' | 'DELETE'; + secret: Secret; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} \ No newline at end of file diff --git a/frontend/src/pages/api/files/batchSecrets.ts b/frontend/src/pages/api/files/batchSecrets.ts new file mode 100644 index 000000000..77060b9a9 --- /dev/null +++ b/frontend/src/pages/api/files/batchSecrets.ts @@ -0,0 +1,38 @@ +import { apiRequest } from "@app/config/request"; + +interface RequestType { + method: string; + secret: { + type: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; + } +} + +const batchSecrets = async ({ + workspaceId, + environment, + requests +}: { + workspaceId: string; + environment: string; + requests: RequestType[]; +}) => { + const { data } = await apiRequest.post('/api/v2/secrets/batch', { + workspaceId, + environment, + requests + }); + + return data; +} + +export default batchSecrets; \ No newline at end of file diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index d0dda0240..ecd360fb0 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -41,9 +41,10 @@ import performSecretRollback from '@app/ee/api/secrets/PerformSecretRollback'; import PITRecoverySidebar from '@app/ee/components/PITRecoverySidebar'; import { useLeaveConfirm } from '@app/hooks'; -import addSecrets from '../api/files/AddSecrets'; -import deleteSecrets from '../api/files/DeleteSecrets'; -import updateSecrets from '../api/files/UpdateSecrets'; +// import addSecrets from '../api/files/AddSecrets'; +// import deleteSecrets from '../api/files/DeleteSecrets'; +// import updateSecrets from '../api/files/UpdateSecrets'; +import batchSecrets from '../api/files/batchSecrets'; import getUser from '../api/user/getUser'; import checkUserAction from '../api/userActions/checkUserAction'; import registerUserAction from '../api/userActions/registerUserAction'; @@ -490,8 +491,18 @@ export default function Dashboard() { })); console.log('override update', overridesToBeUpdated.length); + const requests: any = []; // TODO: fix any if (secretsToBeDeleted.concat(overridesToBeDeleted).length > 0) { - await deleteSecrets({ secretIds: secretsToBeDeleted.concat(overridesToBeDeleted) }); + console.log('DELETE: ', secretsToBeDeleted.concat(overridesToBeDeleted)); + // await deleteSecrets({ secretIds: secretsToBeDeleted.concat(overridesToBeDeleted) }); + secretsToBeDeleted.concat(overridesToBeDeleted).forEach((_id: string) => { + requests.push({ + method: 'DELETE', + secret: { + _id + } + }); + }); } if (selectedEnv && secretsToBeAdded.concat(overridesToBeAdded).length > 0) { const secrets = await encryptSecrets({ @@ -499,7 +510,28 @@ export default function Dashboard() { workspaceId, env: selectedEnv.slug }); - if (secrets) await addSecrets({ secrets, env: selectedEnv.slug, workspaceId }); + if (secrets) { + console.log('ADD: ', secrets); + // await addSecrets({ secrets, env: selectedEnv.slug, workspaceId }); + secrets.forEach((secret) => { + requests.push({ + method: 'POST', + secret: { + type: secret.type, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + secretCommentCiphertext: secret.secretCommentCiphertext, + secretCommentIV: secret.secretCommentIV, + secretCommentTag: secret.secretCommentTag, + tags: secret.tags + } + }) + }); + } } if (selectedEnv && !selectedEnv.isReadDenied && secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) { const secrets = await encryptSecrets({ @@ -507,7 +539,40 @@ export default function Dashboard() { workspaceId, env: selectedEnv.slug }); - if (secrets) await updateSecrets({ secrets }); + if (secrets) { + console.log('UPDATE: ', secrets); + // await updateSecrets({ secrets }); + secrets.forEach((secret) => { + requests.push({ + method: 'PATCH', + secret: { + _id: secret.id, + type: secret.type, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + secretCommentCiphertext: secret.secretCommentCiphertext, + secretCommentIV: secret.secretCommentIV, + secretCommentTag: secret.secretCommentTag, + tags: secret.tags + } + }); + }); + } + } + + if (selectedEnv && requests.length > 0) { + console.log('make batch secret request: '); + const result = await batchSecrets({ + workspaceId, + environment: selectedEnv.slug, + requests + }); + + console.log('result of batchSecrets', result); } setInitialData(structuredClone(newData)); From 11f86da1f67cc73b30b344a4bafb6563a6f4aa2b Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 16 Feb 2023 09:35:57 -0800 Subject: [PATCH 19/49] Fixed the bug with updating tags --- frontend/src/pages/dashboard/[id].tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index ecd360fb0..92f19914e 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -421,9 +421,9 @@ export default function Dashboard() { newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].key !== initDataPoint.key || newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].comment !== - initDataPoint.comment) || + initDataPoint.comment || newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]?.tags !== - initDataPoint?.tags + initDataPoint?.tags) ) .map((secret) => secret.id) .includes(newDataPoint.id) From 625c0785b5eb2eaf85f782eddd9000ff5cdb540d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 17 Feb 2023 01:12:13 +0700 Subject: [PATCH 20/49] Add validation to batch secret endpoint --- .../src/controllers/v2/secretsController.ts | 10 +----- backend/src/routes/v2/secrets.ts | 35 +++++++++++++++---- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 8f516b2dd..5412549b3 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -44,23 +44,15 @@ export const batchSecrets = async (req: Request, res: Response) => { }= req.body; // construct object containing all secrets - // listed across requests const listedSecretsObj: { [key: string]: { version: number; type: string; } - } = (await Secret.find({ - _id: { - $in: requests - .map((request) => request.secret._id) - .filter((secretId) => secretId !== undefined) - } - }).select('version type')).reduce((obj: any, secret: ISecret) => ({ + } = req.secrets.reduce((obj: any, secret: ISecret) => ({ ...obj, [secret._id.toString()]: secret }), {}); - const createSecrets: BatchSecret[] = []; const updateSecrets: BatchSecret[] = []; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 81a288283..a60416ef5 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -6,8 +6,9 @@ import { requireSecretsAuth, validateRequest } from '../../middleware'; -import { query, check, body } from 'express-validator'; +import { query, body } from 'express-validator'; import { secretsController } from '../../controllers/v2'; +import { validateSecrets } from '../../helpers/secret'; import { ADMIN, MEMBER, @@ -15,14 +16,12 @@ import { SECRET_SHARED } from '../../variables'; -// TODO: create batch update endpoint +import { + BatchSecretRequest +} from '../../types/secret'; router.post( '/batch', - body('workspaceId').exists().isString().trim(), - body('environment').exists().isString().trim(), - body('requests').exists(), // perform validation for batch requests - validateRequest, requireAuth({ acceptedAuthModes: ['jwt', 'apiKey'] }), @@ -30,8 +29,30 @@ router.post( acceptedRoles: [ADMIN, MEMBER], location: 'body' }), + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('requests') + .exists() + .custom(async (requests: BatchSecretRequest[], { req }) => { + if (Array.isArray(requests)) { + const secretIds = requests + .map((request) => request.secret._id) + .filter((secretId) => secretId !== undefined) + + if (secretIds.length > 0) { + const relevantSecrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds + }); + + req.secrets = relevantSecrets; + } + } + return true; + }), + validateRequest, secretsController.batchSecrets -) +); router.post( '/', From f2d7401d1df6682ac15f8f974479174a64cd83f2 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 16 Feb 2023 15:47:59 -0800 Subject: [PATCH 21/49] 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 22/49] 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 dbcd2b0988ba7d44b0c92c722bca423204c9dc1a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 17 Feb 2023 11:56:06 +0700 Subject: [PATCH 23/49] Patch undefined req.secrets --- .../src/controllers/v2/secretsController.ts | 36 ++++++++++++------- backend/src/routes/v2/secrets.ts | 1 - 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 5412549b3..c42dcb28b 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -43,17 +43,6 @@ export const batchSecrets = async (req: Request, res: Response) => { requests: BatchSecretRequest[]; }= req.body; - // construct object containing all secrets - const listedSecretsObj: { - [key: string]: { - version: number; - type: string; - } - } = req.secrets.reduce((obj: any, secret: ISecret) => ({ - ...obj, - [secret._id.toString()]: secret - }), {}); - const createSecrets: BatchSecret[] = []; const updateSecrets: BatchSecret[] = []; const deleteSecrets: Types.ObjectId[] = []; @@ -123,7 +112,20 @@ export const batchSecrets = async (req: Request, res: Response) => { // handle update secrets let updatedSecrets: ISecret[] = []; - if (updateSecrets.length > 0) { + if (updateSecrets.length > 0 && req.secrets) { + // construct object containing all secrets + let listedSecretsObj: { + [key: string]: { + version: number; + type: string; + } + } = {}; + + listedSecretsObj = req.secrets.reduce((obj: any, secret: ISecret) => ({ + ...obj, + [secret._id.toString()]: secret + }), {}); + const updateOperations = updateSecrets.map((u) => ({ updateOne: { filter: { _id: new Types.ObjectId(u._id) }, @@ -168,6 +170,14 @@ export const batchSecrets = async (req: Request, res: Response) => { } }); + const updateAction = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: updatedSecrets.map((u) => u._id) + }) as IAction; + actions.push(updateAction); + if (postHogClient) { postHogClient.capture({ event: 'secrets modified', @@ -218,7 +228,7 @@ export const batchSecrets = async (req: Request, res: Response) => { } } - if (actions.length > 1) { + if (actions.length > 0) { // (EE) create (audit) log await EELogService.createLog({ userId: req.user._id.toString(), diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index a60416ef5..51629b32d 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -15,7 +15,6 @@ import { SECRET_PERSONAL, SECRET_SHARED } from '../../variables'; - import { BatchSecretRequest } from '../../types/secret'; From b81d8eba257951edf9cff6e1c787f66fb069d0e1 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 16 Feb 2023 21:05:38 -0800 Subject: [PATCH 24/49] added notification to .env errors --- .../components/context/Notifications/Notification.tsx | 2 +- frontend/src/components/dashboard/DropZone.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/context/Notifications/Notification.tsx b/frontend/src/components/context/Notifications/Notification.tsx index 921f86dec..09c04f5e2 100644 --- a/frontend/src/components/context/Notifications/Notification.tsx +++ b/frontend/src/components/context/Notifications/Notification.tsx @@ -36,7 +36,7 @@ const Notification = ({ notification, clearNotification }: NotificationProps) => return (
{notification.type === 'error' && ( diff --git a/frontend/src/components/dashboard/DropZone.tsx b/frontend/src/components/dashboard/DropZone.tsx index cd6d8650b..c01ed39fa 100644 --- a/frontend/src/components/dashboard/DropZone.tsx +++ b/frontend/src/components/dashboard/DropZone.tsx @@ -8,6 +8,7 @@ import { parseDocument, Scalar, YAMLMap } from 'yaml'; import Button from '../basic/buttons/Button'; import Error from '../basic/Error'; +import { useNotificationContext } from '../context/Notifications/NotificationProvider'; import { parseDotEnv } from '../utilities/parseDotEnv'; import guidGenerator from '../utilities/randomId'; @@ -32,6 +33,7 @@ const DropZone = ({ numCurrentRows }: DropZoneProps) => { const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); const handleDragEnter = (e: DragEvent) => { e.preventDefault(); @@ -110,6 +112,15 @@ const DropZone = ({ const file = e.dataTransfer.files[0]; const reader = new FileReader(); + if (file === undefined) { + createNotification({ + text: `You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.`, + type: 'error', + timeoutMs: 10000 + }); + setLoading(false); + return; + } const fileType = file.name.split('.')[1]; reader.onload = (event) => { From be38844a5b67f8642d5afb576f4ceac4837e6a2e Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Fri, 17 Feb 2023 20:48:19 -0500 Subject: [PATCH 25/49] 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 +} From b0744fd21d52846d94256de536d125e39f852558 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 18 Feb 2023 00:02:52 -0800 Subject: [PATCH 26/49] Wired frontend to use the batch stucture --- frontend/src/components/basic/Toggle.tsx | 12 +- .../components/basic/dialog/DeleteEnvVar.tsx | 8 +- .../src/components/dashboard/AddTagsMenu.tsx | 4 +- .../src/components/dashboard/CommentField.tsx | 8 +- .../dashboard/DashboardInputField.tsx | 24 +- .../dashboard/GenerateSecretMenu.tsx | 8 +- frontend/src/components/dashboard/KeyPair.tsx | 20 +- frontend/src/components/dashboard/SideBar.tsx | 22 +- .../src/components/v2/Popover/Popover.tsx | 8 +- frontend/src/pages/dashboard/[id].tsx | 601 +++++++++++------- 10 files changed, 418 insertions(+), 297 deletions(-) diff --git a/frontend/src/components/basic/Toggle.tsx b/frontend/src/components/basic/Toggle.tsx index 9c6dcf155..313d7009c 100644 --- a/frontend/src/components/basic/Toggle.tsx +++ b/frontend/src/components/basic/Toggle.tsx @@ -3,8 +3,8 @@ import { Switch } from '@headlessui/react'; interface ToggleProps { enabled: boolean; setEnabled: (value: boolean) => void; - addOverride: (value: string | undefined, pos: number) => void; - pos: number; + addOverride: (value: string | undefined, id: string) => void; + id: string; } /** @@ -13,18 +13,18 @@ interface ToggleProps { * @param {boolean} obj.enabled - whether the toggle is turned on or off * @param {function} obj.setEnabled - change the state of the toggle * @param {function} obj.addOverride - a function that adds an override to a certain secret - * @param {number} obj.pos - position of a certain secret + * @param {number} obj.id - id of a certain secret * @returns */ -const Toggle = ({ enabled, setEnabled, addOverride, pos }: ToggleProps): JSX.Element => { +const Toggle = ({ enabled, setEnabled, addOverride, id }: ToggleProps): JSX.Element => { return ( { if (enabled === false) { - addOverride('', pos); + addOverride('', id); } else { - addOverride(undefined, pos); + addOverride(undefined, id); } setEnabled(!enabled); }} diff --git a/frontend/src/components/basic/dialog/DeleteEnvVar.tsx b/frontend/src/components/basic/dialog/DeleteEnvVar.tsx index 48aa93c11..6daed29fd 100644 --- a/frontend/src/components/basic/dialog/DeleteEnvVar.tsx +++ b/frontend/src/components/basic/dialog/DeleteEnvVar.tsx @@ -45,19 +45,19 @@ export const DeleteEnvVar = ({ isOpen, onClose, onSubmit }: Props) => { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - - + + {t('dashboard:sidebar.delete-key-dialog.title')}
-

+

{t('dashboard:sidebar.delete-key-dialog.confirm-delete-message')}

diff --git a/frontend/src/components/dashboard/CommentField.tsx b/frontend/src/components/dashboard/CommentField.tsx index c6552bb8f..3308fdf1b 100644 --- a/frontend/src/components/dashboard/CommentField.tsx +++ b/frontend/src/components/dashboard/CommentField.tsx @@ -6,11 +6,11 @@ import { useTranslation } from 'next-i18next'; const CommentField = ({ comment, modifyComment, - position + id }: { comment: string; - modifyComment: (value: string, posistion: number) => void; - position: number; + modifyComment: (value: string, id: string) => void; + id: string; }) => { const { t } = useTranslation(); @@ -20,7 +20,7 @@ const CommentField = ({