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