Add frontend, backend and CLI

This commit is contained in:
Maidul Islam
2022-11-17 17:54:35 -05:00
parent 8d00c5cdb7
commit bea0ff6e05
276 changed files with 50230 additions and 72 deletions
+102
View File
@@ -0,0 +1,102 @@
import jwt from 'jsonwebtoken';
import * as Sentry from '@sentry/node';
import {
User
} from '../models';
import {
JWT_AUTH_LIFETIME,
JWT_AUTH_SECRET,
JWT_REFRESH_LIFETIME,
JWT_REFRESH_SECRET
} from '../config';
/**
* Return newly issued (JWT) auth and refresh tokens to user with id [userId]
* @param {Object} obj
* @param {String} obj.userId - id of user who we are issuing tokens for
* @return {Object} obj
* @return {String} obj.token - issued JWT token
* @return {String} obj.refreshToken - issued refresh token
*/
const issueTokens = async ({ userId }: { userId: string }) => {
let token: string;
let refreshToken: string;
try {
// issue tokens
token = createToken({
payload: {
userId
},
expiresIn: JWT_AUTH_LIFETIME,
secret: JWT_AUTH_SECRET
});
refreshToken = createToken({
payload: {
userId
},
expiresIn: JWT_REFRESH_LIFETIME,
secret: JWT_REFRESH_SECRET
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to issue tokens');
}
return {
token,
refreshToken
};
};
/**
* Remove JWT and refresh tokens for user with id [userId]
* @param {Object} obj
* @param {String} obj.userId - id of user whose tokens are cleared.
*/
const clearTokens = async ({ userId }: { userId: string }): Promise<void> => {
try {
// increment refreshVersion on user by 1
User.findOneAndUpdate({
_id: userId
}, {
$inc: {
refreshVersion: 1
}
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
}
};
/**
* Return a new (JWT) token for user with id [userId] that expires in [expiresIn]; can be used to, for instance, generate
* bearer/auth, refresh, and temporary signup tokens
* @param {Object} obj
* @param {Object} obj.payload - payload of (JWT) token
* @param {String} obj.secret - (JWT) secret such as [JWT_AUTH_SECRET]
* @param {String} obj.expiresIn - string describing time span such as '10h' or '7d'
*/
const createToken = ({
payload,
expiresIn,
secret
}: {
payload: any;
expiresIn: string | number;
secret: string;
}) => {
try {
return jwt.sign(payload, secret, {
expiresIn
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create a token');
}
};
export { createToken, issueTokens, clearTokens };
View File
+174
View File
@@ -0,0 +1,174 @@
import * as Sentry from '@sentry/node';
import axios from 'axios';
import { IntegrationAuth } from '../models';
import { encryptSymmetric, decryptSymmetric } from '../utils/crypto';
import { IIntegrationAuth } from '../models';
import {
ENCRYPTION_KEY,
OAUTH_CLIENT_SECRET_HEROKU,
OAUTH_TOKEN_URL_HEROKU
} from '../config';
/**
* Process token exchange and refresh responses from respective OAuth2 authorization servers by
* encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt],
* and upserting them into the DB for workspace with id [workspaceId] and integration [integration].
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace
* @param {String} obj.integration - name of integration (e.g. heroku)
* @param {Object} obj.res - response from OAuth2 authorization server
*/
const processOAuthTokenRes = async ({
workspaceId,
integration,
res
}: {
workspaceId: string;
integration: string;
res: any;
}): Promise<IIntegrationAuth> => {
let integrationAuth;
try {
// encrypt refresh + access tokens
const {
ciphertext: refreshCiphertext,
iv: refreshIV,
tag: refreshTag
} = encryptSymmetric({
plaintext: res.data.refresh_token,
key: ENCRYPTION_KEY
});
const {
ciphertext: accessCiphertext,
iv: accessIV,
tag: accessTag
} = encryptSymmetric({
plaintext: res.data.access_token,
key: ENCRYPTION_KEY
});
// compute access token expiration date
const accessExpiresAt = new Date();
accessExpiresAt.setSeconds(
accessExpiresAt.getSeconds() + res.data.expires_in
);
// create or replace integration authorization with encrypted tokens
// and access token expiration date
integrationAuth = await IntegrationAuth.findOneAndUpdate(
{ workspace: workspaceId, integration },
{
workspace: workspaceId,
integration,
refreshCiphertext,
refreshIV,
refreshTag,
accessCiphertext,
accessIV,
accessTag,
accessExpiresAt
},
{ upsert: true, new: true }
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error(
'Failed to process OAuth2 authorization server token response'
);
}
return integrationAuth;
};
/**
* Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on
* the integration authorization document or by requesting a new one by decrypting and exchanging the
* refresh token [refreshCiphertext] with the respective OAuth2 authorization server.
* @param {Object} obj
* @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document
* @returns {String} access token - new access token
*/
const getOAuthAccessToken = async ({
integrationAuth
}: {
integrationAuth: IIntegrationAuth;
}) => {
let accessToken;
try {
const {
refreshCiphertext,
refreshIV,
refreshTag,
accessCiphertext,
accessIV,
accessTag,
accessExpiresAt
} = integrationAuth;
if (
refreshCiphertext &&
refreshIV &&
refreshTag &&
accessCiphertext &&
accessIV &&
accessTag &&
accessExpiresAt
) {
if (accessExpiresAt < new Date()) {
// case: access token expired
// TODO: fetch another access token
let clientSecret;
switch (integrationAuth.integration) {
case 'heroku':
clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
}
// record new access token and refresh token
// encrypt refresh + access tokens
const refreshToken = decryptSymmetric({
ciphertext: refreshCiphertext,
iv: refreshIV,
tag: refreshTag,
key: ENCRYPTION_KEY
});
// TODO: make route compatible with other integration types
const res = await axios.post(
OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable?
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_secret: clientSecret
} as any)
);
accessToken = res.data.access_token;
await processOAuthTokenRes({
workspaceId: integrationAuth.workspace.toString(),
integration: integrationAuth.integration,
res
});
} else {
// case: access token still works
accessToken = decryptSymmetric({
ciphertext: accessCiphertext,
iv: accessIV,
tag: accessTag,
key: ENCRYPTION_KEY
});
}
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get OAuth2 access token');
}
return accessToken;
};
export { processOAuthTokenRes, getOAuthAccessToken };
+62
View File
@@ -0,0 +1,62 @@
import * as Sentry from '@sentry/node';
import { Key, IKey } from '../models';
interface Key {
encryptedKey: string;
nonce: string;
userId: string;
}
/**
* Push (access) [keys] for workspace with id [workspaceId] with
* user with id [userId] as the sender
* @param {Object} obj
* @param {String} obj.userId - id of sender user
* @param {String} obj.workspaceId - id of workspace that keys belong to
* @param {Object[]} obj.keys - (access) keys to push
* @param {String} obj.keys.encryptedKey - encrypted key under receiver's public key
* @param {String} obj.keys.nonce - nonce for encryption
* @param {String} obj.keys.userId - id of receiver user
*/
const pushKeys = async ({
userId,
workspaceId,
keys
}: {
userId: string;
workspaceId: string;
keys: Key[];
}): Promise<void> => {
try {
// filter out already-inserted keys
const keysSet = new Set(
(
await Key.find(
{
workspace: workspaceId
},
'receiver'
)
).map((k: IKey) => k.receiver.toString())
);
keys = keys.filter((key) => !keysSet.has(key.userId));
// add new shared keys only
await Key.insertMany(
keys.map((k) => ({
encryptedKey: k.encryptedKey,
nonce: k.nonce,
sender: userId,
receiver: k.userId,
workspace: workspaceId
}))
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to push access keys');
}
};
export { pushKeys };
+100
View File
@@ -0,0 +1,100 @@
import * as Sentry from '@sentry/node';
import { Membership, Key } from '../models';
/**
* Return membership matching criteria specified in query [queryObj]
* @param {Object} queryObj - query object
* @return {Object} membership - membership
*/
const findMembership = async (queryObj: any) => {
let membership;
try {
membership = await Membership.findOne(queryObj);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to find membership');
}
return membership;
};
/**
* Add memberships for users with ids [userIds] to workspace with
* id [workspaceId]
* @param {Object} obj
* @param {String[]} obj.userIds - id of users.
* @param {String} obj.workspaceId - id of workspace.
* @param {String[]} obj.roles - roles of users.
* @param {String[]} obj.statuses - statuses of users.
*/
const addMemberships = async ({
userIds,
workspaceId,
roles,
statuses
}: {
userIds: string[];
workspaceId: string;
roles: string[];
statuses: string[];
}): Promise<void> => {
try {
const operations = userIds.map((userId, idx) => {
return {
updateOne: {
filter: {
user: userId,
workspace: workspaceId,
role: roles[idx],
status: statuses[idx]
},
update: {
user: userId,
workspace: workspaceId,
role: roles[idx],
status: statuses[idx]
},
upsert: true
}
};
});
await Membership.bulkWrite(operations as any);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to add users to workspace');
}
};
/**
* Delete membership with id [membershipId]
* @param {Object} obj
* @param {String} obj.membershipId - id of membership to delete
*/
const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
let deletedMembership;
try {
deletedMembership = await Membership.findOneAndDelete({
_id: membershipId
});
// delete keys associated with the membership
if (deletedMembership?.user) {
// case: membership had a registered user
await Key.deleteMany({
receiver: deletedMembership.user,
workspace: deletedMembership.workspace
});
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to delete membership');
}
return deletedMembership;
};
export { addMemberships, findMembership, deleteMembership };
+120
View File
@@ -0,0 +1,120 @@
import * as Sentry from '@sentry/node';
import { MembershipOrg, Workspace, Membership, Key } from '../models';
/**
* Return organization membership matching criteria specified in
* query [queryObj]
* @param {Object} queryObj - query object
* @return {Object} membershipOrg - membership
*/
const findMembershipOrg = (queryObj: any) => {
let membershipOrg;
try {
membershipOrg = MembershipOrg.findOne(queryObj);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to find organization membership');
}
return membershipOrg;
};
/**
* Add organization memberships for users with ids [userIds] to organization with
* id [organizationId]
* @param {Object} obj
* @param {String[]} obj.userIds - id of users.
* @param {String} obj.organizationId - id of organization.
* @param {String[]} obj.roles - roles of users.
*/
const addMembershipsOrg = async ({
userIds,
organizationId,
roles,
statuses
}: {
userIds: string[];
organizationId: string;
roles: string[];
statuses: string[];
}) => {
try {
const operations = userIds.map((userId, idx) => {
return {
updateOne: {
filter: {
user: userId,
organization: organizationId,
role: roles[idx],
status: statuses[idx]
},
update: {
user: userId,
organization: organizationId,
role: roles[idx],
status: statuses[idx]
},
upsert: true
}
};
});
await MembershipOrg.bulkWrite(operations as any);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to add users to organization');
}
};
/**
* Delete organization membership with id [membershipOrgId]
* @param {Object} obj
* @param {String} obj.membershipOrgId - id of organization membership to delete
*/
const deleteMembershipOrg = async ({
membershipOrgId
}: {
membershipOrgId: string;
}) => {
let deletedMembershipOrg;
try {
deletedMembershipOrg = await MembershipOrg.findOneAndDelete({
_id: membershipOrgId
});
// delete keys associated with organization membership
if (deletedMembershipOrg?.user) {
// case: organization membership had a registered user
const workspaces = (
await Workspace.find({
organization: deletedMembershipOrg.organization
})
).map((w) => w._id.toString());
await Membership.deleteMany({
user: deletedMembershipOrg.user,
workspace: {
$in: workspaces
}
});
await Key.deleteMany({
receiver: deletedMembershipOrg.user,
workspace: {
$in: workspaces
}
});
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to delete organization membership');
}
return deletedMembershipOrg;
};
export { findMembershipOrg, addMembershipsOrg, deleteMembershipOrg };
+58
View File
@@ -0,0 +1,58 @@
import fs from 'fs';
import path from 'path';
import handlebars from 'handlebars';
import nodemailer from 'nodemailer';
import { SMTP_HOST, SMTP_NAME, SMTP_USERNAME, SMTP_PASSWORD } from '../config';
// create nodemailer transporter
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: 587,
auth: {
user: SMTP_USERNAME,
pass: SMTP_PASSWORD
}
});
transporter
.verify()
.then(() => console.log('SMTP - Successfully connected'))
.catch((err) => console.log('SMTP - Failed to connect'));
/**
* @param {Object} obj
* @param {String} obj.template - email template to use from /templates folder (e.g. testEmail.handlebars)
* @param {String[]} obj.subjectLine - email subject line
* @param {String[]} obj.recipients - email addresses of people to send email to
* @param {Object} obj.substitutions - object containing template substitutions
*/
const sendMail = async ({
template,
subjectLine,
recipients,
substitutions
}: {
template: string;
subjectLine: string;
recipients: string[];
substitutions: any;
}) => {
try {
const html = fs.readFileSync(
path.resolve(__dirname, '../templates/' + template),
'utf8'
);
const temp = handlebars.compile(html);
const htmlToSend = temp(substitutions);
await transporter.sendMail({
from: `"${SMTP_NAME}" <${SMTP_USERNAME}>`,
to: recipients.join(', '),
subject: subjectLine,
html: htmlToSend
});
} catch (err) {
console.error(err);
}
};
export { sendMail };
+168
View File
@@ -0,0 +1,168 @@
import * as Sentry from '@sentry/node';
import Stripe from 'stripe';
import {
STRIPE_SECRET_KEY,
STRIPE_PRODUCT_STARTER,
STRIPE_PRODUCT_PRO
} from '../config';
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01'
});
import { Types } from 'mongoose';
import { ACCEPTED } from '../variables';
import { Organization, MembershipOrg } from '../models';
const productToPriceMap = {
starter: STRIPE_PRODUCT_STARTER,
pro: STRIPE_PRODUCT_PRO
};
/**
* Create an organization with name [name]
* @param {Object} obj
* @param {String} obj.name - name of organization to create.
* @param {String} obj.email - POC email that will receive invoice info
* @param {Object} organization - new organization
*/
const createOrganization = async ({
name,
email
}: {
name: string;
email: string;
}) => {
let organization;
try {
// register stripe account
if (STRIPE_SECRET_KEY) {
const customer = await stripe.customers.create({
email,
description: name
});
organization = await new Organization({
name,
customerId: customer.id
}).save();
} else {
organization = await new Organization({
name
}).save();
}
await initSubscriptionOrg({ organizationId: organization._id });
} catch (err) {
Sentry.setUser({ email });
Sentry.captureException(err);
throw new Error('Failed to create organization');
}
return organization;
};
/**
* Initialize free-tier subscription for new organization
* @param {Object} obj
* @param {String} obj.organizationId - id of associated organization for subscription
* @return {Object} obj
* @return {Object} obj.stripeSubscription - new stripe subscription
* @return {Subscription} obj.subscription - new subscription
*/
const initSubscriptionOrg = async ({
organizationId
}: {
organizationId: Types.ObjectId;
}) => {
let stripeSubscription;
let subscription;
try {
// find organization
const organization = await Organization.findOne({
_id: organizationId
});
if (organization) {
if (organization.customerId) {
// initialize starter subscription with quantity of 0
stripeSubscription = await stripe.subscriptions.create({
customer: organization.customerId,
items: [
{
price: productToPriceMap['starter'],
quantity: 1
}
],
payment_behavior: 'default_incomplete',
proration_behavior: 'none',
expand: ['latest_invoice.payment_intent']
});
}
} else {
throw new Error('Failed to initialize free organization subscription');
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to initialize free organization subscription');
}
return {
stripeSubscription,
subscription
};
};
/**
* Update organization subscription quantity to reflect number of members in
* the organization.
* @param {Object} obj
* @param {Number} obj.organizationId - id of subscription's organization
*/
const updateSubscriptionOrgQuantity = async ({
organizationId
}: {
organizationId: string;
}) => {
let stripeSubscription;
try {
// find organization
const organization = await Organization.findOne({
_id: organizationId
});
if (organization && organization.customerId) {
const quantity = await MembershipOrg.countDocuments({
organization: organizationId,
status: ACCEPTED
});
const subscription = (
await stripe.subscriptions.list({
customer: organization.customerId
})
).data[0];
stripeSubscription = await stripe.subscriptions.update(subscription.id, {
items: [
{
id: subscription.items.data[0].id,
price: subscription.items.data[0].price.id,
quantity
}
]
});
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
}
return stripeSubscription;
};
export {
createOrganization,
initSubscriptionOrg,
updateSubscriptionOrgQuantity
};
+35
View File
@@ -0,0 +1,35 @@
import rateLimit from 'express-rate-limit';
// 300 requests per 15 minutes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
});
// 5 requests per hour
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false
});
// 10 requests per hour
const loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false
});
// 5 requests per hour
const passwordLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false
});
export { apiLimiter, signupLimiter, loginLimiter, passwordLimiter };
+303
View File
@@ -0,0 +1,303 @@
import * as Sentry from '@sentry/node';
import {
Secret,
ISecret
} from '../models';
import { decryptSymmetric } from '../utils/crypto';
import { SECRET_SHARED, SECRET_PERSONAL } from '../variables';
interface PushSecret {
ciphertextKey: string;
ivKey: string;
tagKey: string;
hashKey: string;
ciphertextValue: string;
ivValue: string;
tagValue: string;
hashValue: string;
type: 'shared' | 'personal';
}
interface Update {
[index: string]: string;
}
type DecryptSecretType = 'text' | 'object' | 'expanded';
/**
* Push secrets for user with id [userId] to workspace
* with id [workspaceId] with environment [environment]. Follow steps:
* 1. Handle shared secrets (insert, delete)
* 2. handle personal secrets (insert, delete)
* @param {Object} obj
* @param {String} obj.userId - id of user to push secrets for
* @param {String} obj.workspaceId - id of workspace to push to
* @param {String} obj.environment - environment for secrets
* @param {Object[]} obj.secrets - secrets to push
*/
const pushSecrets = async ({
userId,
workspaceId,
environment,
secrets
}: {
userId: string;
workspaceId: string;
environment: string;
secrets: PushSecret[];
}): Promise<void> => {
try {
// construct useful data structures
const oldSecrets = await pullSecrets({
userId,
workspaceId,
environment
});
const oldSecretsObj: any = oldSecrets.reduce((accumulator, s: any) => {
return { ...accumulator, [s.secretKeyHash]: s };
}, {});
const newSecretsObj = secrets.reduce((accumulator, s) => {
return { ...accumulator, [s.hashKey]: s };
}, {});
// handle deleting secrets
const toDelete = oldSecrets.filter(
(s: ISecret) => !(s.secretKeyHash in newSecretsObj)
);
if (toDelete.length > 0) {
await Secret.deleteMany({
_id: { $in: toDelete.map((s) => s._id) }
});
}
// handle modifying secrets where type or value changed
const operations = secrets
.filter((s) => {
if (s.hashKey in oldSecretsObj) {
if (s.hashValue !== oldSecretsObj[s.hashKey].secretValueHash) {
// case: filter secrets where value changed
return true;
}
if (s.type !== oldSecretsObj[s.hashKey].type) {
// case: filter secrets where type changed
return true;
}
}
return false;
})
.map((s) => {
const update: Update = {
type: s.type,
secretValueCiphertext: s.ciphertextValue,
secretValueIV: s.ivValue,
secretValueTag: s.tagValue,
secretValueHash: s.hashValue
};
if (s.type === SECRET_PERSONAL) {
// attach user assocaited with the personal secret
update['user'] = userId;
}
return {
updateOne: {
filter: {
workspace: workspaceId,
_id: oldSecretsObj[s.hashKey]._id
},
update
}
};
});
const a = await Secret.bulkWrite(operations as any);
// handle adding new secrets
const toAdd = secrets.filter((s) => !(s.hashKey in oldSecretsObj));
if (toAdd.length > 0) {
// add secrets
await Secret.insertMany(
toAdd.map((s, idx) => {
let obj: any = {
workspace: workspaceId,
type: toAdd[idx].type,
environment,
secretKeyCiphertext: s.ciphertextKey,
secretKeyIV: s.ivKey,
secretKeyTag: s.tagKey,
secretKeyHash: s.hashKey,
secretValueCiphertext: s.ciphertextValue,
secretValueIV: s.ivValue,
secretValueTag: s.tagValue,
secretValueHash: s.hashValue
};
if (toAdd[idx].type === 'personal') {
obj['user' as keyof typeof obj] = userId;
}
return obj;
})
);
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to push shared and personal secrets');
}
};
/**
* Pull secrets for user with id [userId] for workspace
* with id [workspaceId] with environment [environment]
* @param {Object} obj
* @param {String} obj.userId -id of user to pull secrets for
* @param {String} obj.workspaceId - id of workspace to pull from
* @param {String} obj.environment - environment for secrets
*
*/
const pullSecrets = async ({
userId,
workspaceId,
environment
}: {
userId: string;
workspaceId: string;
environment: string;
}): Promise<ISecret[]> => {
let secrets: any; // TODO: FIX any
try {
// get shared workspace secrets
const sharedSecrets = await Secret.find({
workspace: workspaceId,
environment,
type: SECRET_SHARED
});
// get personal workspace secrets
const personalSecrets = await Secret.find({
workspace: workspaceId,
environment,
type: SECRET_PERSONAL,
user: userId
});
// concat shared and personal workspace secrets
secrets = personalSecrets.concat(sharedSecrets);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to pull shared and personal secrets');
}
return secrets;
};
/**
* Reformat output of pullSecrets() to be compatible with how existing
* clients handle secrets
* @param {Object} obj
* @param {Object} obj.secrets
*/
const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => {
let reformatedSecrets;
try {
reformatedSecrets = secrets.map((s) => ({
_id: s._id,
workspace: s.workspace,
type: s.type,
environment: s.environment,
secretKey: {
workspace: s.workspace,
ciphertext: s.secretKeyCiphertext,
iv: s.secretKeyIV,
tag: s.secretKeyTag,
hash: s.secretKeyHash
},
secretValue: {
workspace: s.workspace,
ciphertext: s.secretValueCiphertext,
iv: s.secretValueIV,
tag: s.secretValueTag,
hash: s.secretValueHash
}
}));
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to reformat pulled secrets');
}
return reformatedSecrets;
};
/**
* Return decrypted secrets in format [format]
* @param {Object} obj
* @param {Object[]} obj.secrets - array of (encrypted) secret key-value pair objects
* @param {String} obj.key - symmetric key to decrypt secret key-value pairs
* @param {String} obj.format - desired return format that is either "text," "object," or "expanded"
* @return {String|Object} (decrypted) secrets also called the content
*/
const decryptSecrets = ({
secrets,
key,
format
}: {
secrets: PushSecret[];
key: string;
format: DecryptSecretType;
}) => {
// init content
let content: any = format === 'text' ? '' : {};
// decrypt secrets
secrets.forEach((s, idx) => {
const secretKey = decryptSymmetric({
ciphertext: s.ciphertextKey,
iv: s.ivKey,
tag: s.tagKey,
key
});
const secretValue = decryptSymmetric({
ciphertext: s.ciphertextValue,
iv: s.ivValue,
tag: s.tagValue,
key
});
switch (format) {
case 'text':
content += secretKey;
content += '=';
content += secretValue;
if (idx < secrets.length) {
content += '\n';
}
break;
case 'object':
content[secretKey] = secretValue;
break;
case 'expanded':
content[secretKey] = {
...s,
plaintextKey: secretKey,
plaintextValue: secretValue
};
break;
}
});
return content;
};
export {
pushSecrets,
pullSecrets,
reformatPullSecrets,
decryptSecrets
};
+124
View File
@@ -0,0 +1,124 @@
import * as Sentry from '@sentry/node';
import crypto from 'crypto';
import { Token, IToken, IUser } from '../models';
import { createOrganization } from './organization';
import { addMembershipsOrg } from './membershipOrg';
import { createWorkspace } from './workspace';
import { addMemberships } from './membership';
import { OWNER, ADMIN, ACCEPTED, GRANTED } from '../variables';
import { sendMail } from '../helpers/nodemailer';
/**
* Send magic link to verify email to [email]
* for user and workspace.
* @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 }
);
// send mail
await sendMail({
template: 'emailVerification.handlebars',
subjectLine: 'Infisical workspace invitation',
recipients: [email],
substitutions: {
code: token
}
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error(
"Ouch. We weren't able to send your email verification code"
);
}
};
/**
* Validate [code] sent to [email]
* @param {Object} obj
* @param {String} obj.email - emai
* @param {String} obj.code - code that was sent to [email]
*/
const checkEmailVerification = async ({
email,
code
}: {
email: string;
code: string;
}) => {
try {
const token = await Token.findOneAndDelete({
email,
token: code
});
if (!token) throw new Error('Failed to find email verification token');
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error("Oops. We weren't able to verify");
}
};
/**
* Initialize default organization named [organizationName] with workspace
* for user [user]
* @param {Object} obj
* @param {String} obj.organizationName - name of organization to initialize
* @param {IUser} obj.user - user who we are initializing for
*/
const initializeDefaultOrg = async ({
organizationName,
user
}: {
organizationName: string;
user: IUser;
}) => {
try {
// create organization with user as owner and initialize a free
// subscription
const organization = await createOrganization({
email: user.email,
name: organizationName
});
await addMembershipsOrg({
userIds: [user._id.toString()],
organizationId: organization._id.toString(),
roles: [OWNER],
statuses: [ACCEPTED]
});
// initialize a default workspace inside the new organization
const workspace = await createWorkspace({
name: `${user.firstName}'s Project`,
organizationId: organization._id.toString()
});
await addMemberships({
userIds: [user._id.toString()],
workspaceId: workspace._id.toString(),
roles: [ADMIN],
statuses: [GRANTED]
});
} catch (err) {
throw new Error('Failed to initialize default organization and workspace');
}
};
export { sendEmailVerification, checkEmailVerification, initializeDefaultOrg };
+88
View File
@@ -0,0 +1,88 @@
import * as Sentry from '@sentry/node';
import { User, IUser } from '../models';
/**
* Initialize a user under email [email]
* @param {Object} obj
* @param {String} obj.email - email of user to initialize
* @returns {Object} user - the initialized user
*/
const setupAccount = async ({ email }: { email: string }) => {
let user;
try {
user = await new User({
email
}).save();
} catch (err) {
Sentry.setUser({ email });
Sentry.captureException(err);
throw new Error('Failed to set up account');
}
return user;
};
/**
* Finish setting up user
* @param {Object} obj
* @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 {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.salt - salt for auth SRP
* @param {String} obj.verifier - verifier for auth SRP
* @returns {Object} user - the completed user
*/
const completeAccount = async ({
userId,
firstName,
lastName,
publicKey,
encryptedPrivateKey,
iv,
tag,
salt,
verifier
}: {
userId: string;
firstName: string;
lastName: string;
publicKey: string;
encryptedPrivateKey: string;
iv: string;
tag: string;
salt: string;
verifier: string;
}) => {
let user;
try {
const options = {
new: true
};
user = await User.findByIdAndUpdate(
userId,
{
firstName,
lastName,
publicKey,
encryptedPrivateKey,
iv,
tag,
salt,
verifier
},
options
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to complete account set up');
}
return user;
};
export { setupAccount, completeAccount };
+62
View File
@@ -0,0 +1,62 @@
import * as Sentry from '@sentry/node';
import {
Workspace,
Membership,
Key,
Secret
} from '../models';
/**
* Create a workspace with name [name] in organization with id [organizationId]
* @param {String} name - name of workspace to create.
* @param {String} organizationId - id of organization to create workspace in
* @param {Object} workspace - new workspace
*/
const createWorkspace = async ({
name,
organizationId
}: {
name: string;
organizationId: string;
}) => {
let workspace;
try {
workspace = await new Workspace({
name,
organization: organizationId
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create workspace');
}
return workspace;
};
/**
* Delete workspace and all associated materials including memberships,
* secrets, keys, etc.
* @param {Object} obj
* @param {String} obj.id - id of workspace to delete
*/
const deleteWorkspace = async ({ id }: { id: string }) => {
try {
await Workspace.deleteOne({ _id: id });
await Membership.deleteMany({
workspace: id
});
await Secret.deleteMany({
workspace: id
});
await Key.deleteMany({
workspace: id
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to delete workspace');
}
};
export { createWorkspace, deleteWorkspace };