From 5cadb9e2f93d6d9507a0fa70934c4bb36edf033c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 23 Jan 2023 22:10:15 +0700 Subject: [PATCH 001/101] 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 002/101] 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 003/101] 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')}
-
+
-
} -
+ {currentWorkspace ? ( +
+

+ Project +

+ +
+ ) : ( +
+ +
+ )} +
@@ -392,7 +400,7 @@ export const AppLayout = ({ children }: LayoutProps) => { )} /> -
+
{ isDisabled={isSubmitting} isLoading={isSubmitting} key="layout-create-project-submit" - className="" + className="mr-4" type="submit" > Create Project +
From 409de81bd2dad59699fb4bd9f40af5a9d6065997 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Sun, 12 Feb 2023 09:34:52 -0800 Subject: [PATCH 012/101] Allow sign up disable --- backend/src/config/index.ts | 2 ++ backend/src/controllers/v1/signupController.ts | 9 +++++++-- docs/self-hosting/configuration/envars.mdx | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index a7194308e..1ea6bc3a1 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,5 +1,6 @@ const PORT = process.env.PORT || 4000; const EMAIL_TOKEN_LIFETIME = parseInt(process.env.EMAIL_TOKEN_LIFETIME! || '86400'); +const DISABLE_NEW_SIGN_UP = process.env.DISABLE_NEW_SIGN_UP == undefined ? false : process.env.DISABLE_NEW_SIGN_UP const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; const SALT_ROUNDS = parseInt(process.env.SALT_ROUNDS!) || 10; const JWT_AUTH_LIFETIME = process.env.JWT_AUTH_LIFETIME! || '10d'; @@ -50,6 +51,7 @@ const LICENSE_KEY = process.env.LICENSE_KEY!; export { PORT, EMAIL_TOKEN_LIFETIME, + DISABLE_NEW_SIGN_UP, ENCRYPTION_KEY, SALT_ROUNDS, JWT_AUTH_LIFETIME, diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 62e5a62a3..dc9860f43 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 { NODE_ENV, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, DISABLE_NEW_SIGN_UP } from '../../config'; import { User, MembershipOrg } from '../../models'; import { completeAccount } from '../../helpers/user'; import { @@ -11,6 +11,7 @@ import { import { issueTokens, createToken } from '../../helpers/auth'; import { INVITED, ACCEPTED } from '../../variables'; import axios from 'axios'; +import { BadRequestError } from '../../utils/errors'; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -24,6 +25,10 @@ export const beginEmailSignup = async (req: Request, res: Response) => { try { email = req.body.email; + if (DISABLE_NEW_SIGN_UP) { + throw BadRequestError({ message: "New signups are not permitted at this time" }) + } + const user = await User.findOne({ email }).select('+publicKey'); if (user && user?.publicKey) { // case: user has already completed account @@ -129,7 +134,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { // 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 diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 39d6542c9..42af4da79 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -37,5 +37,6 @@ Configuring Infisical requires setting some environment variables. There is a fi | `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` | | `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` | | `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | -| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | +| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | | `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | +| `DISABLE_NEW_SIGN_UP` | Block new sign ups on your self hosted instance | `false` | From 2022988e773a432dcbb25dbb4f7622dae13448e9 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Sun, 12 Feb 2023 10:34:32 -0800 Subject: [PATCH 013/101] Only allow sign up when invted --- backend/src/config/index.ts | 4 ++-- backend/src/controllers/v1/signupController.ts | 10 +++++++--- docs/self-hosting/configuration/envars.mdx | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1ea6bc3a1..1d8665c72 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,6 +1,6 @@ const PORT = process.env.PORT || 4000; const EMAIL_TOKEN_LIFETIME = parseInt(process.env.EMAIL_TOKEN_LIFETIME! || '86400'); -const DISABLE_NEW_SIGN_UP = process.env.DISABLE_NEW_SIGN_UP == undefined ? false : process.env.DISABLE_NEW_SIGN_UP +const INVITE_ONLY_SIGNUP = process.env.INVITE_ONLY_SIGNUP == undefined ? false : process.env.INVITE_ONLY_SIGNUP const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; const SALT_ROUNDS = parseInt(process.env.SALT_ROUNDS!) || 10; const JWT_AUTH_LIFETIME = process.env.JWT_AUTH_LIFETIME! || '10d'; @@ -51,7 +51,7 @@ const LICENSE_KEY = process.env.LICENSE_KEY!; export { PORT, EMAIL_TOKEN_LIFETIME, - DISABLE_NEW_SIGN_UP, + INVITE_ONLY_SIGNUP, ENCRYPTION_KEY, SALT_ROUNDS, JWT_AUTH_LIFETIME, diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index dc9860f43..dad9632db 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, DISABLE_NEW_SIGN_UP } from '../../config'; +import { NODE_ENV, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, INVITE_ONLY_SIGNUP } from '../../config'; import { User, MembershipOrg } from '../../models'; import { completeAccount } from '../../helpers/user'; import { @@ -25,8 +25,12 @@ export const beginEmailSignup = async (req: Request, res: Response) => { try { email = req.body.email; - if (DISABLE_NEW_SIGN_UP) { - throw BadRequestError({ message: "New signups are not permitted at this time" }) + if (INVITE_ONLY_SIGNUP) { + // Only one user can create an account without being invited. The rest need to be invited in order to make an account + const userCount = await User.countDocuments({}) + if (userCount != 0) { + throw BadRequestError({ message: "New user sign ups are not allowed at this time. You must be invited to sign up." }) + } } const user = await User.findOne({ email }).select('+publicKey'); diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 42af4da79..804df78c2 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -39,4 +39,4 @@ Configuring Infisical requires setting some environment variables. There is a fi | `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | | `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | | `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | -| `DISABLE_NEW_SIGN_UP` | Block new sign ups on your self hosted instance | `false` | +| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` | From a61233d2ba0241352e5789a97bdd49b91c679791 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Sun, 12 Feb 2023 14:22:59 -0800 Subject: [PATCH 014/101] Release docker images for cli --- .github/workflows/release_build.yml | 12 ++-- .goreleaser.yaml | 88 +++++++++++++---------------- cli/docker/Dockerfile | 4 ++ 3 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 cli/docker/Dockerfile diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index 3d11a1157..af395ce6c 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -4,7 +4,7 @@ on: push: # run only against tags tags: - - 'v*' + - "v*" permissions: contents: write @@ -18,11 +18,16 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - run: git fetch --force --tags - run: echo "Ref name ${{github.ref_name}}" - uses: actions/setup-go@v3 with: - go-version: '>=1.19.3' + go-version: ">=1.19.3" cache: true cache-dependency-path: cli/go.sum - name: libssl1.1 => libssl1.0-dev for OSXCross @@ -45,8 +50,7 @@ jobs: AUR_KEY: ${{ secrets.AUR_KEY }} - uses: actions/setup-python@v4 - run: pip install --upgrade cloudsmith-cli - - name: Publish to CloudSmith + - name: Publish to CloudSmith run: sh cli/upload_to_cloudsmith.sh env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fc39224aa..8e9c575d9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -68,10 +68,10 @@ archives: release: replace_existing_draft: true - mode: 'replace' + mode: "replace" checksum: - name_template: 'checksums.txt' + name_template: "checksums.txt" snapshot: name_template: "{{ incpatch .Version }}-devel" @@ -80,8 +80,8 @@ changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' + - "^docs:" + - "^test:" # publishers: # - name: fury.io @@ -109,30 +109,30 @@ brews: man1.install "manpages/infisical.1.gz" nfpms: -- id: infisical - package_name: infisical - builds: - - all-other-builds - vendor: Infisical, Inc - homepage: https://infisical.com/ - maintainer: Infisical, Inc - description: The offical Infisical CLI - license: MIT - formats: - - rpm - - deb - - apk - - archlinux - bindir: /usr/bin - contents: - - src: ./completions/infisical.bash - dst: /etc/bash_completion.d/infisical - - src: ./completions/infisical.fish - dst: /usr/share/fish/vendor_completions.d/infisical.fish - - src: ./completions/infisical.zsh - dst: /usr/share/zsh/site-functions/_infisical - - src: ./manpages/infisical.1.gz - dst: /usr/share/man/man1/infisical.1.gz + - id: infisical + package_name: infisical + builds: + - all-other-builds + vendor: Infisical, Inc + homepage: https://infisical.com/ + maintainer: Infisical, Inc + description: The offical Infisical CLI + license: MIT + formats: + - rpm + - deb + - apk + - archlinux + bindir: /usr/bin + contents: + - src: ./completions/infisical.bash + dst: /etc/bash_completion.d/infisical + - src: ./completions/infisical.fish + dst: /usr/share/fish/vendor_completions.d/infisical.fish + - src: ./completions/infisical.zsh + dst: /usr/share/zsh/site-functions/_infisical + - src: ./manpages/infisical.1.gz + dst: /usr/share/man/man1/infisical.1.gz scoop: bucket: @@ -146,15 +146,14 @@ scoop: license: MIT aurs: - - - name: infisical-bin + - name: infisical-bin homepage: "https://infisical.com" description: "The official Infisical CLI" maintainers: - Infisical, Inc license: MIT - private_key: '{{ .Env.AUR_KEY }}' - git_url: 'ssh://aur@aur.archlinux.org/infisical-bin.git' + private_key: "{{ .Env.AUR_KEY }}" + git_url: "ssh://aur@aur.archlinux.org/infisical-bin.git" package: |- # bin install -Dm755 "./infisical" "${pkgdir}/usr/bin/infisical" @@ -169,19 +168,12 @@ aurs: install -Dm644 "./completions/infisical.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/infisical.fish" # man pages install -Dm644 "./manpages/infisical.1.gz" "${pkgdir}/usr/share/man/man1/infisical.1.gz" -# dockers: -# - dockerfile: goreleaser.dockerfile -# goos: linux -# goarch: amd64 -# ids: -# - infisical -# image_templates: -# - "infisical/cli:{{ .Version }}" -# - "infisical/cli:{{ .Major }}.{{ .Minor }}" -# - "infisical/cli:{{ .Major }}" -# - "infisical/cli:latest" -# build_flag_templates: -# - "--label=org.label-schema.schema-version=1.0" -# - "--label=org.label-schema.version={{.Version}}" -# - "--label=org.label-schema.name={{.ProjectName}}" -# - "--platform=linux/amd64" \ No newline at end of file +dockers: + - dockerfile: cli/docker/Dockerfile + goos: linux + goarch: amd64 + ids: + - infisical + image_templates: + - "infisical/cli:{{ .Version }}" + - "infisical/cli:latest" diff --git a/cli/docker/Dockerfile b/cli/docker/Dockerfile new file mode 100644 index 000000000..0436d4d8e --- /dev/null +++ b/cli/docker/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine +RUN apk add --no-cache tini +COPY infisical /bin/infisical +ENTRYPOINT ["/sbin/tini", "--", "/bin/infisical"] \ No newline at end of file From 17f9e53779ef7c7c2edbf5b6f66cc37c465e8f41 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sun, 12 Feb 2023 17:54:22 -0800 Subject: [PATCH 015/101] Updated the dashabord, members, and settings pages --- .../controllers/v1/organizationController.ts | 30 ++++++------ .../controllers/v2/environmentController.ts | 5 +- frontend/public/data/frequentInterfaces.ts | 2 +- .../basic/table/ProjectUsersTable.tsx | 45 ++++++++++++----- .../src/components/basic/table/UserTable.tsx | 21 +++++++- .../context/Notifications/Notification.tsx | 8 +-- .../dashboard/DashboardInputField.tsx | 41 ++++++++-------- frontend/src/components/dashboard/KeyPair.tsx | 18 ++++++- frontend/src/components/dashboard/SideBar.tsx | 12 ++--- .../utilities/secrets/encryptSecrets.ts | 2 +- .../utilities/secrets/getSecretsForProject.ts | 19 ++++--- .../src/components/v2/Popover/Popover.tsx | 49 +++++++++++++++++++ frontend/src/components/v2/Popover/index.tsx | 2 + frontend/src/components/v2/Select/Select.tsx | 13 +++-- .../src/ee/components/PITRecoverySidebar.tsx | 7 +-- .../src/ee/components/SecretVersionList.tsx | 12 ++--- .../organization/GetOrgProjectMemberships.ts | 24 +++++++++ frontend/src/pages/dashboard/[id].tsx | 36 +++++++++----- frontend/src/pages/settings/org/[id].tsx | 2 +- frontend/src/pages/settings/personal/[id].tsx | 2 +- 20 files changed, 252 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/v2/Popover/Popover.tsx create mode 100644 frontend/src/components/v2/Popover/index.tsx create mode 100644 frontend/src/pages/api/organization/GetOrgProjectMemberships.ts diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index eaf58fad7..66326e560 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -397,9 +397,21 @@ export const getOrganizationMembersAndTheirWorkspaces = async ( res: Response ) => { const { organizationId } = req.params; - const orgMemberships = await MembershipOrg.find({ organization: organizationId }); - const userIds = orgMemberships.map(orgMembership => orgMembership.user); - const memberships = await Membership.find({ user: { $in: userIds } }); + + const workspacesSet = ( + await Workspace.find( + { + organization: organizationId + }, + '_id' + ) + ).map((w) => w._id.toString()); + + const memberships = ( + await Membership.find({ + workspace: { $in: workspacesSet } + }).populate('workspace') + ); const userToWorkspaceIds: any = {}; memberships.forEach(membership => { @@ -411,15 +423,5 @@ export const getOrganizationMembersAndTheirWorkspaces = async ( } }); - const workspaceIds = Object.values(userToWorkspaceIds).flat() - const workspacesList = await Workspace.find({ - organization: organizationId, - _id: { $in: workspaceIds } - }); - - const populatedUserWorkspaces = _.mapValues(userToWorkspaceIds, workspaceIds => - _.map(workspaceIds, id => _.find(workspacesList, { _id: id })) - ); - - return res.json(populatedUserWorkspaces); + return res.json(userToWorkspaceIds); }; \ No newline at end of file diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 7a0d5e1c5..b82dca9fe 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -246,13 +246,14 @@ export const getAllAccessibleEnvironmentsOfWorkspace = async ( relatedWorkspace.environments.forEach(environment => { const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_READ }) const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_WRITE }) - if (isReadBlocked) { + if (isReadBlocked && isWriteBlocked) { return } else { accessibleEnvironments.push({ name: environment.name, slug: environment.slug, - isWriteDenied: isWriteBlocked + isWriteDenied: isWriteBlocked, + isReadDenied: isReadBlocked }) } }) diff --git a/frontend/public/data/frequentInterfaces.ts b/frontend/public/data/frequentInterfaces.ts index 9865d9909..fa6c73a57 100644 --- a/frontend/public/data/frequentInterfaces.ts +++ b/frontend/public/data/frequentInterfaces.ts @@ -10,7 +10,7 @@ export interface Tag { export interface SecretDataProps { pos: number; key: string; - value: string; + value: string | undefined; valueOverride: string | undefined; id: string; comment: string; diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 9d278bdc4..27346195c 100644 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { faX } from '@fortawesome/free-solid-svg-icons'; +import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; import { plans } from 'public/data/frequentConstants'; import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; @@ -106,6 +106,11 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { ability: "read", environmentSlug: slug }]; + } else if (val === "Add Only") { + denials = [{ + ability: "read", + environmentSlug: slug + }]; } else { denials = []; } @@ -185,21 +190,21 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { return (
-
+
- + {workspaceEnvs.map(env => ( - ))} @@ -221,7 +226,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { user.email?.toLowerCase().includes(filter) ) .map((row, index) => ( - + @@ -231,7 +236,8 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { - {workspaceEnvs.map((env) => )}
NAME EMAIL ROLE - {env.name.toUpperCase()}
+
+ {env.slug.toUpperCase()}
{/* PERMISSION */}
{row.firstName} {row.lastName}
+ {workspaceEnvs.map((env) => diff --git a/frontend/src/components/basic/table/UserTable.tsx b/frontend/src/components/basic/table/UserTable.tsx index 02e65c547..c14d77b08 100644 --- a/frontend/src/components/basic/table/UserTable.tsx +++ b/frontend/src/components/basic/table/UserTable.tsx @@ -4,6 +4,7 @@ import { faX } from '@fortawesome/free-solid-svg-icons'; import changeUserRoleInOrganization from '@app/pages/api/organization/changeUserRoleInOrganization'; import deleteUserFromOrganization from '@app/pages/api/organization/deleteUserFromOrganization'; +import getOrganizationProjectMemberships from '@app/pages/api/organization/GetOrgProjectMemberships'; import deleteUserFromWorkspace from '@app/pages/api/workspace/deleteUserFromWorkspace'; import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey'; import uploadKeys from '@app/pages/api/workspace/uploadKeys'; @@ -36,6 +37,8 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg } ); const router = useRouter(); const [myRole, setMyRole] = useState('member'); + const [userProjectMemberships, setUserProjectMemberships] = useState([]); + console.log(123, userData) const workspaceId = router.query.id as string; // Delete the row in the table (e.g. a user) @@ -79,6 +82,10 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg } useEffect(() => { setMyRole(userData.filter((user) => user.email === myUser)[0]?.role); + (async () => { + const result = await getOrganizationProjectMemberships({ orgId: String(localStorage.getItem("orgData.id"))}) + setUserProjectMemberships(result); + })(); }, [userData, myUser]); const grantAccess = async (id: string, publicKey: string) => { @@ -110,7 +117,7 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg } }; return ( -
+
@@ -118,6 +125,7 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg } + @@ -189,6 +197,17 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg } )} + +
NAME EMAIL ROLEPROJECTS
+ + {userProjectMemberships[row.userId] + ? userProjectMemberships[row.userId]?.map((project: any) => ( +
+ {project.name} +
+ )) + : This user isn't part of any projects yet.} +
{myUser !== row.email && // row.role !== "admin" && diff --git a/frontend/src/components/context/Notifications/Notification.tsx b/frontend/src/components/context/Notifications/Notification.tsx index ca1b155bd..921f86dec 100644 --- a/frontend/src/components/context/Notifications/Notification.tsx +++ b/frontend/src/components/context/Notifications/Notification.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { faX } from '@fortawesome/free-solid-svg-icons'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; type NotificationType = 'success' | 'error' | 'info'; @@ -36,7 +36,7 @@ const Notification = ({ notification, clearNotification }: NotificationProps) => return (
{notification.type === 'error' && ( @@ -48,13 +48,13 @@ const Notification = ({ notification, clearNotification }: NotificationProps) => {notification.type === 'info' && (
)} -

{notification.text}

+

{notification.text}

); diff --git a/frontend/src/components/dashboard/DashboardInputField.tsx b/frontend/src/components/dashboard/DashboardInputField.tsx index a55c3ba61..a5ee0ed3a 100644 --- a/frontend/src/components/dashboard/DashboardInputField.tsx +++ b/frontend/src/components/dashboard/DashboardInputField.tsx @@ -1,9 +1,10 @@ import { memo, SyntheticEvent, useRef } from 'react'; -import { faCircle, faExclamationCircle, faEye, faLayerGroup } from '@fortawesome/free-solid-svg-icons'; +import { faCircle, faCodeBranch, faExclamationCircle, faEye } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import guidGenerator from '../utilities/randomId'; import { HoverObject } from '../v2/HoverCard'; +import { PopoverObject } from '../v2/Popover/Popover'; const REGEX = /([$]{.*?})/g; @@ -112,7 +113,7 @@ const DashboardInputField = ({ }}> @@ -125,24 +126,24 @@ const DashboardInputField = ({ const error = startsWithNumber || isDuplicate; return ( -
-
- onChangeHandler(e.target.value, position)} - type={type} - value={value} - className='z-10 peer ph-no-capture bg-transparent py-2.5 caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200' - spellCheck="false" - placeholder='–' - /> + +
+
+ {value?.split("\n")[0] ? + {value?.split("\n")[0]} + : - } + {value?.split("\n")[1] && + {value?.split("\n")[1]} + } +
-
+ ); } if (type === 'value') { @@ -215,7 +216,7 @@ const DashboardInputField = ({ ))} {value?.split('').length === 0 && EMPTY}
-
+
)} diff --git a/frontend/src/components/dashboard/KeyPair.tsx b/frontend/src/components/dashboard/KeyPair.tsx index 711d25a51..de55b7322 100644 --- a/frontend/src/components/dashboard/KeyPair.tsx +++ b/frontend/src/components/dashboard/KeyPair.tsx @@ -132,7 +132,7 @@ const KeyPair = ({ /> -
+
- { if (deleteRow) { deleteRow({ ids: [keyPair.id], secretName: keyPair?.key }) }}} isPlain /> + :
+
null} + role="button" + tabIndex={0} + onClick={() => { if (deleteRow) { + deleteRow({ ids: [keyPair.id], secretName: keyPair?.key }) + }}} + className="invisible group-hover:visible" + > + +
+
}
diff --git a/frontend/src/components/dashboard/SideBar.tsx b/frontend/src/components/dashboard/SideBar.tsx index 1d0996376..6fac52ed7 100644 --- a/frontend/src/components/dashboard/SideBar.tsx +++ b/frontend/src/components/dashboard/SideBar.tsx @@ -18,7 +18,7 @@ import GenerateSecretMenu from './GenerateSecretMenu'; interface SecretProps { key: string; - value: string; + value: string | undefined; valueOverride: string | undefined; pos: number; id: string; @@ -80,9 +80,9 @@ const SideBar = ({ const { t } = useTranslation(); return ( -
+
{isLoading ? ( -
+
) : ( -
+

{t('dashboard:sidebar.secret')}

)} -
+