feat(infisical-pg): completed signup, login, password and backup key migration

This commit is contained in:
Akhil Mohan
2023-12-04 14:48:11 +05:30
parent 05205d1eff
commit fcfd6b3fb2
68 changed files with 7529 additions and 58 deletions

11
backend-pg/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.env
.env.*
.git
.gitignore
Dockerfile
.dockerignore
docker-compose.*
.DS_Store
*.swp
*~

14
backend-pg/Dockerfile.dev Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm install
COPY . .
ENV HOST=0.0.0.0
CMD ["npm", "run", "dev:docker"]

6
backend-pg/nodemon.json Normal file
View File

@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": ".ts,.js",
"ignore": [],
"exec": "tsx ./src/server/app.ts | pino-pretty --colorize --colorizeObjects --singleLine"
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,15 +6,28 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx watch --clear-screen=false ./src/server/app.ts | pino-pretty --colorize --colorizeObjects --singleLine",
"dev:docker": "nodemon",
"type:check": "tsc --noEmit",
"lint:fix": "eslint --fix 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'"
"lint": "eslint 'src/**/*.ts'",
"generate:component": "tsx ./scripts/create-backend-file.ts",
"migration:new": "tsx ./scripts/create-migration.ts",
"migration:up": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:up",
"migration:down": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:down",
"migration:list": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:list",
"migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest",
"migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsrp": "^0.2.6",
"@types/node": "^20.9.5",
"@types/nodemailer": "^6.4.14",
"@types/prompt-sync": "^4.2.3",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
@@ -24,7 +37,9 @@
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"nodemon": "^3.0.2",
"pino-pretty": "^10.2.3",
"prompt-sync": "^4.2.0",
"ts-node": "^10.9.1",
"tsx": "^4.4.0",
"typescript": "^5.3.2"
@@ -36,11 +51,18 @@
"@fastify/rate-limit": "^9.0.0",
"@fastify/swagger": "^8.12.0",
"@fastify/swagger-ui": "^1.10.1",
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1",
"eslint-config-airbnb-typescript": "^17.1.0",
"fastify": "^4.24.3",
"fastify-plugin": "^4.5.1",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4",
"knex": "^3.0.1",
"nodemailer": "^6.9.7",
"ora": "^7.0.1",
"pg": "^8.11.3",
"pino": "^8.16.2",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.0"

View File

@@ -0,0 +1,81 @@
import { mkdirSync, writeFileSync } from "fs";
import path from "path";
import promptSync from "prompt-sync";
const prompt = promptSync({
sigint: true
});
console.log(`
Component List
--------------
1. Service component
2. Schema file
`);
const componentType = parseInt(prompt("Select a component: "), 10);
if (componentType === 1) {
const componentName = prompt("Enter service name: ");
const dir = path.join(__dirname, `../src/services/${componentName}`);
const capitalizedComponentName = componentName.at(0)?.toUpperCase() + componentName.slice(1);
const dalTypeName = `T${capitalizedComponentName}DalFactory`;
const dalName = `${componentName}DalFactory`;
const serviceTypeName = `T${capitalizedComponentName}ServiceFactory`;
const serviceName = `${componentName}ServiceFactory`;
mkdirSync(dir);
writeFileSync(
path.join(dir, `${componentName}-dal.ts`),
`import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
export type ${dalTypeName} = {};
export const ${dalName} = (db: TDbClient): ${dalTypeName} => {
return { };
};
`
);
writeFileSync(
path.join(dir, `${componentName}-service.ts`),
`import { ${dalTypeName} } from "./${componentName}-dal";
type ${serviceTypeName}Dep = {
${componentName}Dal: ${dalTypeName};
};
export type ${serviceTypeName} = ReturnType<typeof ${serviceName}>;
export const ${serviceName} = ({ ${componentName}Dal }: ${serviceTypeName}Dep) => {
return {};
};
`
);
writeFileSync(path.join(dir, `${componentName}-types.ts`), "");
} else if (componentType === 2) {
const componentName = prompt("Type component name in lowercase with space seperated: ");
const dashcase = componentName.split(" ").join("-");
const pascalCase = componentName
.split(" ")
.reduce(
(prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`,
""
);
writeFileSync(
path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`),
`
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const ${pascalCase}Schema = z.object({});
export type T${pascalCase} = z.infer<typeof ${pascalCase}Schema>;
export type T${pascalCase}Insert = Omit<T${pascalCase}, TImmutableDBKeys>;
export type T${pascalCase}Update = Partial<Omit<T${pascalCase}, TImmutableDBKeys>>;
`
);
}

View File

@@ -0,0 +1,15 @@
import { execSync } from "child_process";
import path from "path";
import promptSync from "prompt-sync";
const prompt = promptSync();
const migrationName = prompt("Enter name for migration: ");
execSync(
`npx knex migrate:make --knexfile ${path.join(
__dirname,
"../src/db/knexfile.ts"
)} -x ts ${migrationName}`,
{ stdio: "inherit" }
);

19
backend-pg/src/@types/fastify-zod.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
import {
FastifyInstance,
RawReplyDefaultExpression,
RawRequestDefaultExpression,
RawServerDefault
} from "fastify";
import { Logger } from "pino";
import { ZodTypeProvider } from "@app/server/plugins/fastify-zod";
declare global {
type FastifyZodProvider = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
RawReplyDefaultExpression<RawServerDefault>,
Readonly<Logger>,
ZodTypeProvider
>;
}

View File

@@ -1,19 +1,32 @@
import { ZodTypeProvider } from "@app/server/plugins/fastify-zod";
import { TUser } from "@app/db/schemas";
import { TAuthDalFactory } from "@app/services/auth/auth-dal";
import { TAuthLoginFactory } from "@app/services/auth/auth-login-service";
import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service";
import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service";
import "fastify";
declare module "fastify" {
interface FastifyRequest {
realIp: string;
// used for mfa session authentication
mfa: {
userId: string;
user: TUser;
};
}
interface FastifyInstance {
services: {
login: TAuthLoginFactory;
password: TAuthPasswordFactory;
signup: TAuthSignupFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
store: {
user: Pick<TAuthDalFactory, "getUserById">;
};
}
}
declare global {
type FastifyZodProvider = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
RawReplyDefaultExpression<RawServerDefault>,
FastifyBaseLogger,
ZodTypeProvider
>;
}

43
backend-pg/src/@types/knex.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
import {
TableName,
TBackupPrivateKey,
TBackupPrivateKeyInsert,
TToken,
TTokenInsert,
TTokenUpdate,
TUser,
TUserEncryptionKey,
TUserEncryptionKeyInsert,
TUserEncryptionKeyUpdate,
TUserInsert,
TUserUpdate
} from "@app/db/schemas";
import {
TTokenSession,
TTokenSessionInsert,
TTokenSessionUpdate
} from "@app/db/schemas/token-session";
declare module "knex/types/tables" {
interface Tables extends { [key in TableName]: Knex.CompositeTableType<any> } {
[TableName.Users]: Knex.CompositeTableType<TUser, TUserInsert, TUserUpdate>;
[TableName.UserEncryptionKey]: Knex.CompositeTableType<
TUserEncryptionKey,
TUserEncryptionKeyInsert,
TUserEncryptionKeyUpdate
>;
[TableName.AuthTokens]: Knex.CompositeTableType<TToken, TTokenInsert, TTokenUpdate>;
[TableName.AuthTokenSession]: Knex.CompositeTableType<
TTokenSession,
TTokenSessionInsert,
TTokenSessionUpdate
>;
[TableName.BackupPrivateKey]: Knex.CompositeTableType<
TBackupPrivateKey,
TBackupPrivateKeyInsert,
TTokenSessionUpdate
>;
}
}

View File

@@ -0,0 +1,2 @@
export type { TDbClient } from "./instance";
export { initDbConnection } from "./instance";

View File

@@ -0,0 +1,11 @@
import knex from "knex";
export type TDbClient = ReturnType<typeof initDbConnection>;
export const initDbConnection = (dbConnectionUri: string) => {
const db = knex({
client: "pg",
connection: dbConnectionUri
});
return db;
};

View File

@@ -0,0 +1,24 @@
import dotenv from "dotenv";
import type { Knex } from "knex";
import path from "path";
// eslint-disable-next-line
import "ts-node/register";
// Update with your config settings.
dotenv.config({
path: path.join(__dirname, "../../.env"),
debug: true
});
export default {
useNullAsDefault: true,
client: "postgres",
connection: process.env.DB_CONNECTION_URI,
pool: {
min: 2,
max: 10
},
migrations: {
tableName: "infisical_migrations"
}
} as Knex.Config;

View File

@@ -0,0 +1,37 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import {
createOnUpdateTrigger,
createUpdateAtTriggerFunction,
dropOnUpdateTrigger,
dropUpdatedAtTriggerFunction
} from "../utils";
export async function up(knex: Knex): Promise<void> {
const isTablePresent = await knex.schema.hasTable(TableName.Users);
if (!isTablePresent) {
await knex.schema.createTable(TableName.Users, (t) => {
t.uuid("id").primary().defaultTo(knex.fn.uuid());
t.string("email").notNullable();
t.specificType("authMethods", "text[]");
t.boolean("superAdmin").defaultTo(false);
t.string("firstName");
t.string("lastName");
t.boolean("isAccepted").defaultTo(false);
t.boolean("isMfaEnabled").defaultTo(false);
t.specificType("mfaMethods", "text[]");
t.jsonb("devices");
t.timestamps(true, true, true);
});
}
// this is a one time function
await createUpdateAtTriggerFunction(knex);
await createOnUpdateTrigger(knex, TableName.Users);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.Users);
await dropOnUpdateTrigger(knex, TableName.Users);
await dropUpdatedAtTriggerFunction(knex);
}

View File

@@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const isTablePresent = await knex.schema.hasTable(TableName.UserEncryptionKey);
if (!isTablePresent) {
await knex.schema.createTable(TableName.UserEncryptionKey, (t) => {
t.increments().primary();
t.text("clientPublicKey");
t.text("serverPrivateKey");
t.text("encryptionVersion").defaultTo(1);
t.text("protectedKey").notNullable();
t.text("protectedKeyIV").notNullable();
t.text("protectedKeyTag").notNullable();
t.text("publicKey").notNullable();
t.text("encryptedPrivateKey").notNullable();
t.text("iv").notNullable();
t.text("tag").notNullable();
t.text("salt").notNullable();
t.text("verifier").notNullable();
// one to one relationship
t.uuid("userId").notNullable().unique();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.UserEncryptionKey);
}

View File

@@ -0,0 +1,24 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const isTablePresent = await knex.schema.hasTable(TableName.AuthTokens);
if (!isTablePresent) {
await knex.schema.createTable(TableName.AuthTokens, (t) => {
t.string("type").notNullable();
t.string("phoneNumber");
t.string("tokenHash").notNullable();
t.integer("triesLeft");
t.datetime("expiresAt").notNullable();
// does not need update trigger we will do it manually
t.timestamps(true, true, true);
t.uuid("userId");
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.AuthTokens);
}

View File

@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
const isTablePresent = await knex.schema.hasTable(TableName.AuthTokenSession);
if (!isTablePresent) {
await knex.schema.createTable(TableName.AuthTokenSession, (t) => {
t.increments();
t.string("ip").notNullable();
t.string("userAgent");
t.integer("refreshVersion").notNullable().defaultTo(1);
t.integer("accessVersion").notNullable().defaultTo(1);
t.datetime("lastUsed").notNullable();
// does not need update trigger we will do it manually
t.timestamps(true, true, true);
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
});
}
// this is a one time function
await createOnUpdateTrigger(knex, TableName.AuthTokenSession);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.AuthTokenSession);
await dropOnUpdateTrigger(knex, TableName.AuthTokenSession);
}

View File

@@ -0,0 +1,26 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const doesTableExist = await knex.schema.hasTable(TableName.BackupPrivateKey);
if (!doesTableExist) {
await knex.schema.createTable(TableName.BackupPrivateKey, (t) => {
t.increments();
t.string("encryptedPrivateKey").notNullable();
t.string("iv").notNullable();
t.string("tag").notNullable();
t.string("algorithm").notNullable();
t.string("keyEncoding").notNullable();
t.string("salt").notNullable();
t.string("verifier").notNullable();
t.timestamps(true, true, true);
t.uuid("userId").notNullable().unique();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.BackupPrivateKey);
}

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
import { SecretEncryptionAlgo, SecretKeyEncoding, TImmutableDBKeys } from "./models";
export const BackupPrivateKeySchema = z.object({
id: z.string(),
userId: z.string(),
encryptedPrivateKey: z.string(),
iv: z.string(),
tag: z.string(),
algorithm: z.nativeEnum(SecretEncryptionAlgo),
keyEncoding: z.nativeEnum(SecretKeyEncoding),
salt: z.string(),
verifier: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime()
});
export type TBackupPrivateKey = z.infer<typeof BackupPrivateKeySchema>;
export type TBackupPrivateKeyInsert = Omit<TBackupPrivateKey, TImmutableDBKeys>;
export type TBackupPrivateKeyUpdate = Partial<Omit<TBackupPrivateKey, TImmutableDBKeys>>;

View File

@@ -0,0 +1,15 @@
export {
BackupPrivateKeySchema,
TBackupPrivateKey,
TBackupPrivateKeyInsert,
TBackupPrivateKeyUpdate
} from "./backup-private-key";
export { TableName } from "./models";
export { TokenSchema, TToken, TTokenInsert, TTokenUpdate } from "./token";
export { AuthMethod, TUser, TUserInsert, TUserUpdate, UserSchema } from "./user";
export {
TUserEncryptionKey,
TUserEncryptionKeyInsert,
TUserEncryptionKeyUpdate,
UserEncryptionKey
} from "./user-encryption-key";

View File

@@ -0,0 +1,19 @@
export enum TableName {
Users = "users",
UserEncryptionKey = "user_encryption_keys",
AuthTokens = "auth_tokens",
AuthTokenSession = "auth_token_sessions",
BackupPrivateKey = "backup_private_key"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
export enum SecretEncryptionAlgo {
AES_256_GCM = "aes-256-gcm"
}
export enum SecretKeyEncoding {
UTF8 = "utf8",
BASE64 = "base64",
HEX = "hex"
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const TokenSessionSchema = z.object({
id: z.string(),
userId: z.string(),
ip: z.string(),
userAgent: z.string(),
refreshVersion: z.number().default(1),
accessVersion: z.number().default(1),
lastUsed: z.string().datetime()
});
export type TTokenSession = z.infer<typeof TokenSessionSchema>;
export type TTokenSessionInsert = Omit<TTokenSession, TImmutableDBKeys>;
export type TTokenSessionUpdate = Partial<Omit<TTokenSession, TImmutableDBKeys>>;

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const TokenSchema = z.object({
id: z.number(),
type: z.enum(["emailConfirmation", "emailMfa", "organizationInvitation", "passwordReset"]),
phoneNumber: z.string().optional(),
tokenHash: z.string(),
triesLeft: z.number().optional(),
expiresAt: z.string().datetime().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
userId: z.string().optional()
});
export type TToken = z.infer<typeof TokenSchema>;
export type TTokenInsert = Omit<TToken, TImmutableDBKeys>;
export type TTokenUpdate = Partial<Omit<TToken, TImmutableDBKeys>>;

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import { PickRequired } from "@app/lib/types";
import { TImmutableDBKeys } from "./models";
export const UserEncryptionKey = z.object({
id: z.number(),
userId: z.string(),
serverPrivateKey: z.string().optional().nullable(),
clientPublicKey: z.string().optional().nullable(),
encryptionVersion: z.number().default(1).optional(),
protectedKey: z.string(),
protectedKeyIV: z.string(),
protectedKeyTag: z.string(),
publicKey: z.string(),
encryptedPrivateKey: z.string(),
iv: z.string(),
tag: z.string(),
salt: z.string(),
verifier: z.string()
});
export type TUserEncryptionKey = z.infer<typeof UserEncryptionKey>;
export type TUserEncryptionKeyInsert = Omit<PickRequired<TUserEncryptionKey>, TImmutableDBKeys>;
export type TUserEncryptionKeyUpdate = Partial<Omit<TUserEncryptionKey, TImmutableDBKeys>>;

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export enum AuthMethod {
EMAIL = "email",
GOOGLE = "google",
GITHUB = "github",
GITLAB = "gitlab",
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml"
}
export const UserSchema = z.object({
id: z.string(),
authMethods: z.nativeEnum(AuthMethod).array().default([AuthMethod.EMAIL]).optional().nullable(),
email: z.string(),
isSuperAdmin: z.boolean().default(false).optional(),
firstName: z.string().optional().nullable(),
lastName: z.string().optional().nullable(),
isMfaEnabled: z.boolean().default(false).optional(),
mfaMethods: z.string().array().default([]).optional().nullable(),
isAccepted: z.boolean().default(false).optional(),
devices: z.string().nullable().optional()
});
export const UserDeviceSchema = z
.object({
ip: z.string(),
userAgent: z.string()
})
.array()
.default([]);
export type TUser = z.infer<typeof UserSchema>;
export type TUserInsert = Omit<TUser, TImmutableDBKeys>;
export type TUserUpdate = Partial<Omit<TUser, "id" | "email">>;

View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
export const createJunctionTable = (
knex: Knex,
tableName: string,
table1Name: string,
table2Name: string
) =>
knex.schema.createTable(tableName, (table) => {
table.increments(); // Primary key
table.integer(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1
table.integer(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2
table.foreign(`${table1Name}Id`).references("id").inTable(table2Name);
table.foreign(`${table2Name}Id`).references("id").inTable(table1Name);
});
// one time logic
// this is a postgres function log to set updateAt to present time whenever row gets updated
export const createUpdateAtTriggerFunction = (knex: Knex) =>
knex.raw(`
CREATE OR REPLACE FUNCTION on_update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW."updatedAt" = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
export const dropUpdatedAtTriggerFunction = (knex: Knex) =>
knex.raw(`
DROP FUNCTION IF EXISTS on_update_timestamp() CASCADE;
`);
// we would be using this to apply updatedAt where ever we wanta
// remember to set `timestamps(true,true,true)` before this on schema
export const createOnUpdateTrigger = (knex: Knex, tableName: string) =>
knex.raw(`
CREATE TRIGGER ${tableName}_updatedAt
BEFORE UPDATE ON ${tableName}
FOR EACH ROW
EXECUTE PROCEDURE on_update_timestamp();
`);
export const dropOnUpdateTrigger = (knex: Knex, tableName: string) =>
knex.raw(`DROP TRIGGER IF EXISTS ${tableName}_updatedAt ON ${tableName}`);

View File

@@ -1,16 +1,39 @@
import { Logger } from "pino";
import { z } from "zod";
import { zpStr } from "../zod";
const zodStrBool = z
.enum(["true", "false"])
.optional()
.transform((val) => val === "true");
const envSchema = z.object({
PORT: z.coerce.number().default(4000),
HOST: zpStr(z.string().default("localhost")),
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database conntection string")),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SALT_ROUNDS: z.coerce.number().default(10),
// TODO(akhilmhdh): will be changed to one
ENCRYPTION_KEY: z.string().optional(),
ROOT_ENCRYPTION_KEY: z.string().optional(),
HTTPS_ENABLED: z
.enum(["true", "false"])
.optional()
.transform((val) => val === "true")
ENCRYPTION_KEY: zpStr(z.string().optional()),
ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()),
HTTPS_ENABLED: zodStrBool,
// smtp options
SMTP_HOST: zpStr(z.string().optional()),
SMTP_SECURE: zodStrBool,
SMTP_PORT: z.coerce.number().default(587),
SMTP_USERNAME: zpStr(z.string().optional()),
SMTP_PASSWORD: zpStr(z.string().optional()),
SMTP_FROM_ADDRESS: zpStr(z.string().optional()),
SMTP_FROM_NAME: zpStr(z.string().optional().default("Infisical")),
COOKIE_SECRET_SIGN_KEY: z.string().default("g5giLbOMpaJhqEogXApkiw2ZFW5Q0jvA"),
SITE_URL: zpStr(z.string().optional()),
// jwt options
JWT_AUTH_SECRET: zpStr(z.string()),
JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")),
JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")),
JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")),
JWT_MFA_LIFETIME: zpStr(z.string().default("5m"))
});
let envCfg: Readonly<z.infer<typeof envSchema>>;
@@ -21,8 +44,20 @@ export const initEnvConfig = (logger: Logger) => {
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
logger.error("Invalid environment variables. Check the error below");
logger.error(parsedEnv.error);
logger.error(parsedEnv.error.issues);
process.exit(-1);
}
envCfg = Object.freeze(parsedEnv.data);
return envCfg;
};
export const formatSmtpConfig = () => ({
host: envCfg.SMTP_HOST,
port: envCfg.SMTP_PORT,
auth:
envCfg.SMTP_USERNAME && envCfg.SMTP_PASSWORD
? { user: envCfg.SMTP_USERNAME, pass: envCfg.SMTP_PASSWORD }
: undefined,
secure: envCfg.SMTP_SECURE,
from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>`
});

View File

@@ -0,0 +1 @@
export { generateSrpServerKey, srpCheckClientProof } from "./srp";

View File

@@ -0,0 +1,26 @@
import jsrp from "jsrp";
export const generateSrpServerKey = async (salt: string, verifier: string) => {
// eslint-disable-next-line new-cap
const server = new jsrp.server();
await new Promise((resolve) => {
server.init({ salt, verifier }, () => resolve(null));
});
return { pubKey: server.getPublicKey(), privateKey: server.getPrivateKey() };
};
export const srpCheckClientProof = async (
salt: string,
verifier: string,
serverPrivateKey: string,
clientPublicKey: string,
clientProof: string
) => {
// eslint-disable-next-line new-cap
const server = new jsrp.server();
await new Promise((resolve) => {
server.init({ salt, verifier, b: serverPrivateKey }, () => resolve(null));
});
server.setClientPublicKey(clientPublicKey);
return server.checkClientProof(clientProof);
};

View File

@@ -0,0 +1,36 @@
/* eslint-disable max-classes-per-file */
export class DatabaseError extends Error {
name: string;
error: unknown;
constructor({ name, error, message }: { message?: string; name: string; error: unknown }) {
super(message || "Failed to execute db ops");
this.name = name;
this.error = error;
}
}
export class UnauthorizedError extends Error {
name: string;
error: unknown;
constructor({ name, error, message }: { message?: string; name: string; error: unknown }) {
super(message ?? "You are not allowed to access this resourve");
this.name = name;
this.error = error;
}
}
export class BadRequestError extends Error {
name: string;
error: unknown;
constructor({ name, error, message }: { message?: string; name: string; error: unknown }) {
super(message ?? "The request is invalid");
this.name = name;
this.error = error;
}
}

View File

@@ -0,0 +1,5 @@
export type RequiredKeys<T> = {
[K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];
export type PickRequired<T> = Pick<T, RequiredKeys<T>>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { isDisposableEmail } from "./validate-email";

View File

@@ -0,0 +1,10 @@
import fs from "fs/promises";
import path from "path";
export const isDisposableEmail = async (email: string) => {
const emailDomain = email.split("@")[1];
const disposableEmails = await fs.readFile(path.join(__dirname, "disposable_emails.txt"), "utf8");
if (disposableEmails.split("\n").includes(emailDomain)) return true;
return false;
};

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
// this is a patched zod string to remove empty string to undefined
export const zpStr = <T extends z.ZodString>(
schema: T,
opt: { stripNull: boolean } = { stripNull: true }
) =>
z.preprocess((val) => {
if (opt.stripNull && val === null) return undefined;
if (typeof val !== "string") return val;
return val.trim() || undefined;
}, schema);

View File

@@ -1,6 +1,5 @@
import dotenv from "dotenv";
import fasitfy from "fastify";
import { z } from "zod";
import type { FastifyCookieOptions } from "@fastify/cookie";
import cookie from "@fastify/cookie";
import type { FastifyCorsOptions } from "@fastify/cors";
@@ -9,20 +8,24 @@ import helmet from "@fastify/helmet";
import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
import ratelimiter from "@fastify/rate-limit";
import { initEnvConfig } from "@lib/config/env";
import { initDbConnection } from "@app/db";
import { smtpServiceFactory } from "@app/services/smtp/smtp-service";
import { formatSmtpConfig, initEnvConfig } from "@lib/config/env";
import { initLogger } from "@lib/logger";
import { globalRateLimiterCfg } from "./config/rateLimiter";
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "./plugins/fastify-zod";
import { fastifyIp } from "./plugins/ip";
import { fastifySwagger } from "./plugins/swagger";
import { registerRoutes } from "./routes";
dotenv.config();
// Run the server!
const main = async () => {
const logger = await initLogger();
initEnvConfig(logger);
const envCfg = initEnvConfig(logger);
const server = fasitfy({
logger,
@@ -32,42 +35,31 @@ const main = async () => {
server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler);
const db = initDbConnection(envCfg.DB_CONNECTION_URI);
const smtp = smtpServiceFactory(formatSmtpConfig());
try {
// TODO(akhilmhdh:pg): change this to environment variable with default
await server.register<FastifyCookieOptions>(cookie, {
secret: "infisical-cookie-secret"
secret: envCfg.COOKIE_SECRET_SIGN_KEY
});
await server.register<FastifyCorsOptions>(cors, {
credentials: true,
origin: "http://localhost:3000"
origin: true
});
// pull ip based on various proxy headers
await server.register(fastifyIp);
// Rate limiters and security headers
await server.register<FastifyRateLimitOptions>(ratelimiter, globalRateLimiterCfg);
await server.register(helmet);
await server.register(fastifySwagger);
// Declare a route
server.route({
method: "GET",
url: "/",
schema: {
response: {
200: z.object({ hello: z.string() })
}
},
handler: () => ({
hello: "world"
})
});
// Rate limiters and security headers
await server.register<FastifyRateLimitOptions>(ratelimiter, globalRateLimiterCfg);
await server.register(helmet, { contentSecurityPolicy: false });
await server.register(registerRoutes, { prefix: "/api", smtp, db });
await server.ready();
server.swagger();
await server.listen({ port: 8000 });
await server.listen({ port: envCfg.PORT, host: envCfg.HOST });
} catch (err) {
server.log.error(err);
process.exit(1);

View File

@@ -4,6 +4,7 @@ import swaggerUI from "@fastify/swagger-ui";
import { jsonSchemaTransform } from "./fastify-zod";
// TODO(akhilmhdh-pg): change the localhost port later
export const fastifySwagger = fp(async (fastify) => {
await fastify.register(swagger, {
transform: jsonSchemaTransform,
@@ -15,12 +16,12 @@ export const fastifySwagger = fp(async (fastify) => {
},
servers: [
{
url: "https://app.infisical.com",
description: "Production server"
url: "http://localhost:4000",
description: "Local server"
},
{
url: "http://localhost:8000",
description: "Local server"
url: "https://app.infisical.com",
description: "Production server"
}
],
components: {

View File

@@ -0,0 +1,44 @@
import { Knex } from "knex";
import { authDalFactory } from "@app/services/auth/auth-dal";
import { authLoginServiceFactory } from "@app/services/auth/auth-login-service";
import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service";
import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { tokenDalFactory } from "@app/services/token/token-dal";
import { tokenServiceFactory } from "@app/services/token/token-service";
import { registerV1Routes } from "./v1";
import { registerV2Routes } from "./v2";
import { registerV3Routes } from "./v3";
export const registerRoutes = async (
server: FastifyZodProvider,
{ db, smtp }: { db: Knex; smtp: TSmtpService }
) => {
// db layers
const authDal = authDalFactory(db);
const authTokenDal = tokenDalFactory(db);
// service layers
const tokenService = tokenServiceFactory({ tokenDal: authTokenDal });
const loginService = authLoginServiceFactory({ authDal, smtpService: smtp, tokenService });
const passwordService = authPaswordServiceFactory({ tokenService, smtpService: smtp, authDal });
const signupService = authSignupServiceFactory({ tokenService, smtpService: smtp, authDal });
// inject all services
server.decorate("services", {
login: loginService,
password: passwordService,
signup: signupService
} as FastifyZodProvider["services"]);
server.decorate("store", {
user: authDal
} as FastifyZodProvider["store"]);
// register routes for v1
await server.register(registerV1Routes, { prefix: "/v1" });
await server.register(registerV2Routes, { prefix: "/v2" });
await server.register(registerV3Routes, { prefix: "/v3" });
};

View File

@@ -0,0 +1,5 @@
import { registerPasswordRouter } from "./password-router";
export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerPasswordRouter, { prefix: "/password" });
};

View File

@@ -0,0 +1,115 @@
import { z } from "zod";
import { BackupPrivateKeySchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
export const registerPasswordRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/srp1",
schema: {
body: z.object({
clientPublicKey: z.string().trim()
}),
response: {
200: z.object({
serverPublicKey: z.string(),
salt: z.string()
})
}
},
handler: async (req) => {
const { salt, serverPublicKey } = await server.services.password.generateServerPubKey(
req.auth.userId,
req.body.clientPublicKey
);
return { salt, serverPublicKey };
}
});
server.route({
method: "POST",
url: "/change-password",
schema: {
body: z.object({
clientProof: z.string().trim(),
protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
encryptedPrivateKeyIV: z.string().trim(),
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim()
}),
response: {
200: z.object({
message: z.string()
})
}
},
handler: async (req, res) => {
const appCfg = getConfig();
await server.services.password.changePassword({ ...req.body, userId: req.auth.userId });
res.cookie("jid", appCfg.COOKIE_SECRET_SIGN_KEY, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return { message: "Successfully changed password" };
}
});
server.route({
method: "POST",
url: "/backup-private-key",
schema: {
body: z.object({
clientProof: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
iv: z.string().trim(),
tag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim()
}),
response: {
200: z.object({
message: z.string(),
backupPrivateKey: BackupPrivateKeySchema
})
}
},
handler: async (req) => {
const backupPrivateKey = await server.services.password.createBackupPrivateKey({
...req.body,
userId: req.auth.userId
});
if (!backupPrivateKey) throw new Error("Failed to create backup key");
return { message: "Successfully updated backup private key", backupPrivateKey };
}
});
server.route({
method: "GET",
url: "/backup-private-key",
schema: {
response: {
200: z.object({
message: z.string(),
backupPrivateKey: BackupPrivateKeySchema
})
}
},
handler: async (req) => {
const backupPrivateKey = await server.services.password.getBackupPrivateKeyOfUser(
req.auth.userId
);
if (!backupPrivateKey) throw new Error("Failed to find backup key");
return { message: "Successfully updated backup private key", backupPrivateKey };
}
});
};

View File

@@ -0,0 +1,5 @@
import { registerMfaRouter } from "./mfa-router";
export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerMfaRouter, { prefix: "/auth" });
};

View File

@@ -0,0 +1,89 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { AuthTokenType } from "@app/services/auth/auth-signup-type";
export const registerMfaRouter = async (server: FastifyZodProvider) => {
const cfg = getConfig();
server.addHook("preParsing", async (req, res) => {
const authorizationHeader = req.headers.authorization;
if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) {
res.status(401).send({ error: "Missing bearer token" });
return;
}
const token = authorizationHeader.split(" ")[1];
if (!token) res.status(401).send({ error: "Missing bearer token" });
const decodedToken = jwt.verify(token, cfg.JWT_AUTH_SECRET) as JwtPayload;
if (decodedToken.authTokenType !== AuthTokenType.MFA_TOKEN)
throw new Error("Unauthorized access");
const user = await server.store.user.getUserById(decodedToken.userId);
if (!user) throw new Error("User not found");
req.mfa.userId = user.id;
req.mfa.user = user;
});
server.route({
url: "/mfa/send",
method: "POST",
schema: {
response: {
200: z.object({
message: z.string()
})
}
},
handler: async (req) => {
await server.services.login.resendMfaToken(req.mfa.userId);
return { message: "Successfully send new mfa code" };
}
});
server.route({
url: "/mfa/verify",
method: "POST",
schema: {
body: z.object({
mfaToken: z.string().trim()
}),
response: {
200: z.object({
encryptionVersion: z.number().default(1).optional(),
protectedKey: z.string(),
protectedKeyIV: z.string(),
protectedKeyTag: z.string(),
publicKey: z.string(),
encryptedPrivateKey: z.string(),
iv: z.string(),
tag: z.string(),
token: z.string()
})
}
},
handler: async (req, res) => {
const userAgent = req.headers["user-agent"];
if (!userAgent) throw new Error("user agent header is required");
const appCfg = getConfig();
const { user, token } = await server.services.login.verifyMfaToken({
userAgent,
ip: req.realIp,
userId: req.mfa.userId,
mfaToken: req.body.mfaToken
});
res.setCookie("jid", token.refresh, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return { token: token.access, ...user };
}
});
};

View File

@@ -0,0 +1,7 @@
import { registerLoginRouter } from "./login-router";
import { registerSignupRouter } from "./signup-router";
export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerSignupRouter, { prefix: "/signup" });
await server.register(registerLoginRouter, { prefix: "/auth" });
};

View File

@@ -0,0 +1,97 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
export const registerLoginRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/login1",
schema: {
body: z.object({
email: z.string().email().trim(),
providerAuthToken: z.string().trim().optional(),
clientPublicKey: z.string().trim()
}),
response: {
200: z.object({
serverPublicKey: z.string(),
salt: z.string()
})
}
},
handler: async (req) => {
const { serverPublicKey, salt } = await server.services.login.loginGenServerPublicKey({
email: req.body.email,
clientPublicKey: req.body.clientPublicKey,
providerAuthToken: req.body.providerAuthToken
});
return { serverPublicKey, salt };
}
});
server.route({
method: "POST",
url: "/login2",
schema: {
body: z.object({
email: z.string().email().trim(),
providerAuthToken: z.string().trim().optional(),
clientProof: z.string().trim()
}),
response: {
200: z.discriminatedUnion("mfaEnabled", [
z.object({ mfaEnabled: z.literal(true), token: z.string() }),
z.object({
mfaEnabled: z.literal(false),
encryptionVersion: z.number().default(1).optional(),
protectedKey: z.string(),
protectedKeyIV: z.string(),
protectedKeyTag: z.string(),
publicKey: z.string(),
encryptedPrivateKey: z.string(),
iv: z.string(),
tag: z.string(),
token: z.string()
})
])
}
},
handler: async (req, res) => {
const userAgent = req.headers["user-agent"];
if (!userAgent) throw new Error("user agent header is required");
const appCfg = getConfig();
const data = await server.services.login.loginExchangeClientProof({
email: req.body.email,
ip: req.realIp,
userAgent,
clientProof: req.body.clientProof
});
if (data.isMfaEnabled) {
return { mfaEnabled: true, token: data.token } as const; // for discriminated union
}
res.setCookie("jid", data.token.refresh, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return {
mfaEnabled: false,
encryptionVersion: data.user.encryptionVersion,
token: data.token.access,
publicKey: data.user.publicKey,
encryptedPrivateKey: data.user.encryptedPrivateKey,
iv: data.user.iv,
tag: data.user.tag,
protectedKey: data.user.protectedKey,
protectedKeyIV: data.user.protectedKeyIV,
protectedKeyTag: data.user.protectedKeyTag
} as const;
}
});
};

View File

@@ -0,0 +1,103 @@
import { z } from "zod";
import { UserSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
export const registerSignupRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/email/signup",
method: "POST",
schema: {
body: z.object({
email: z.string().email().trim()
}),
response: {
200: z.object({
message: z.string()
})
}
},
handler: async (req) => {
await server.services.signup.beginEmailSignupProcess(req.body.email);
return { message: `Sent an email verification code to ${req.body.email}` };
}
});
server.route({
url: "/email/verify",
method: "POST",
schema: {
body: z.object({
email: z.string().email().trim(),
code: z.string().trim()
}),
response: {
200: z.object({
message: z.string(),
token: z.string(),
user: UserSchema
})
}
},
handler: async (req) => {
const { token, user } = await server.services.signup.verifyEmailSignup(
req.body.email,
req.body.code
);
return { message: "Successfuly verified email", token, user };
}
});
server.route({
url: "/complete-account/signup",
method: "POST",
schema: {
body: z.object({
email: z.string().email().trim(),
firstName: z.string().trim(),
lastName: z.string().trim().optional(),
protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(),
publicKey: z.string().trim(),
encryptedPrivateKey: z.string().trim(),
encryptedPrivateKeyIV: z.string().trim(),
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim(),
organizationName: z.string().trim(),
providerAuthToken: z.string().trim().optional().nullish(),
attributionSource: z.string().trim().optional()
}),
response: {
200: z.object({
message: z.string(),
user: UserSchema,
token: z.string()
})
}
},
handler: async (req, res) => {
const userAgent = req.headers["user-agent"];
if (!userAgent) throw new Error("user agent header is required");
const appCfg = getConfig();
const { user, accessToken, refreshToken } =
await server.services.signup.completeEmailAccountSignup({
...req.body,
ip: req.realIp,
userAgent
});
res.setCookie("jid", refreshToken, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
// TODO(akhilmhdh-pg): add telemetry service
return { message: "Successfully set up account", user, token: accessToken };
}
});
};

View File

@@ -0,0 +1,131 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TBackupPrivateKey, TUser, TUserEncryptionKey } from "@app/db/schemas";
export type TAuthDalFactory = ReturnType<typeof authDalFactory>;
export const authDalFactory = (db: TDbClient) => {
// getters
const getUserByEmail = async (email: string): Promise<TUser | undefined> =>
db(TableName.Users).where({ email }).select("*").first();
const getUserById = async (userId: string): Promise<TUser | undefined> =>
db(TableName.Users).where({ id: userId }).select("*").first();
const getUserEncKeyByEmail = async (email: string) =>
db(TableName.Users)
.where({ email })
.join(
TableName.UserEncryptionKey,
`${TableName.Users}.id`,
`${TableName.UserEncryptionKey}.userId`
)
.first();
const getUserEncKeyByUserId = async (userId: string) =>
db(TableName.Users)
.where({ id: userId })
.join(
TableName.UserEncryptionKey,
`${TableName.Users}.id`,
`${TableName.UserEncryptionKey}.userId`
)
.first();
const getBackupPrivateKeyByUserId = async (userId: string) =>
db(TableName.BackupPrivateKey).where({ userId }).first("*");
// all inserts and updates
const createUser = async (
email: string,
data: Partial<TUser> = {}
): Promise<TUser | undefined> => {
const [user] = await db(TableName.Users)
.insert({ email, ...data })
.returning("*");
return user;
};
const updateUser = async (
email: string,
data: Partial<TUser> = {}
): Promise<TUser | undefined> => {
const [user] = await db(TableName.Users)
.where({ email })
.update({ ...data })
.returning("*");
return user;
};
const updateUserById = async (
id: string,
data: Partial<TUser> = {},
tx?: Knex
): Promise<TUser | undefined> => {
const [user] = await (tx ? tx(TableName.Users) : db(TableName.Users))
.where({ id })
.update({ ...data })
.returning("*");
return user;
};
const updateUserEncryptionByUserId = async (
userId: string,
data: Partial<TUserEncryptionKey> = {},
tx?: Knex
): Promise<TUserEncryptionKey | undefined> => {
const [userEnc] = await (tx ? tx(TableName.UserEncryptionKey) : db(TableName.UserEncryptionKey))
.where({ userId })
.update({ ...data })
.returning("*");
return userEnc;
};
// all upserts
const upsertUserEncryptionKey = async (
userId: string,
data: Partial<TUserEncryptionKey>,
tx?: Knex
) => {
const [userEnc] = await (tx ? tx(TableName.UserEncryptionKey) : db(TableName.UserEncryptionKey))
// if user insert make sure to pass all required data
.insert({ userId, ...data } as TUserEncryptionKey)
.onConflict("userId")
.merge()
.returning("*");
return userEnc;
};
const upsertBackupKey = async (
userId: string,
data: Partial<TBackupPrivateKey>,
tx?: Knex
): Promise<TBackupPrivateKey | undefined> => {
const [backupKey] = await (tx ? tx(TableName.BackupPrivateKey) : db(TableName.BackupPrivateKey))
.insert({ userId, ...data, updatedAt: new Date().toUTCString() } as TBackupPrivateKey)
.onConflict("userId")
.merge()
.returning("*");
return backupKey;
};
return {
transaction: async <T>(cb: (tx: Knex) => T) =>
db.transaction(async (trx) => {
const res = await cb(trx);
return res;
}),
getUserByEmail,
getUserById,
getUserEncKeyByEmail,
getUserEncKeyByUserId,
getBackupPrivateKeyByUserId,
createUser,
updateUser,
updateUserById,
updateUserEncryptionByUserId,
upsertUserEncryptionKey,
upsertBackupKey
};
};

View File

@@ -0,0 +1,249 @@
import jwt from "jsonwebtoken";
import { AuthMethod, TUser } from "@app/db/schemas";
import { UserDeviceSchema } from "@app/db/schemas/user";
import { getConfig } from "@app/lib/config/env";
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTokenServiceFactory } from "../token/token-service";
import { TokenType } from "../token/token-types";
import { TAuthDalFactory } from "./auth-dal";
import {
TLoginClientProofDTO,
TLoginGenServerPublicKeyDTO,
TVerifyMfaTokenDTO
} from "./auth-login-type";
import { AuthTokenType } from "./auth-signup-type";
const isValidProviderAuthToken = (email: string, jwtSecret: string, providerAuthToken?: string) => {
if (!providerAuthToken) return false;
const decodedToken = jwt.verify(providerAuthToken, jwtSecret) as jwt.JwtPayload;
if (decodedToken.authTokenType !== AuthTokenType.PROVIDER_TOKEN) return false;
if (decodedToken.email !== email) return false;
return true;
};
type TAuthLoginServiceFactoryDep = {
authDal: TAuthDalFactory;
tokenService: TTokenServiceFactory;
smtpService: TSmtpService;
};
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
export const authLoginServiceFactory = ({
authDal,
tokenService,
smtpService
}: TAuthLoginServiceFactoryDep) => {
/*
* Private
* Not exported. This is to update user device list
* If new device is found. Will be saved and a mail will be send
*/
const updateUserDeviceSession = async (user: TUser, ip: string, userAgent: string) => {
const devices = await UserDeviceSchema.parseAsync(JSON.parse(user.devices || "[]"));
const isDeviceSeen = devices.some(
(device) => device.ip === ip && device.userAgent === userAgent
);
if (!isDeviceSeen) {
const newDeviceList = devices.concat([{ ip, userAgent }]);
await authDal.updateUserById(user.id, { devices: JSON.stringify(newDeviceList) });
await smtpService.sendMail({
template: SmtpTemplates.NewDeviceJoin,
subjectLine: "Successful login from new device",
recipients: [user.email],
substitutions: {
email: user.email,
timestamp: new Date().toString(),
ip,
userAgent
}
});
}
};
/*
* Private
* Send mfa code via email
* */
const sendUserMfaCode = async (user: TUser) => {
const code = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId: user.id
});
await smtpService.sendMail({
template: SmtpTemplates.EmailMfa,
subjectLine: "Infisical MFA code",
recipients: [user.email],
substitutions: {
code
}
});
};
/* Private
* Check user device and send mail if new device
* generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled
*/
const generateUserTokens = async (user: TUser, ip: string, userAgent: string) => {
const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent);
const tokenSession = await tokenService.getUserTokenSession({
userAgent,
ip,
userId: user.id
});
if (!tokenSession) throw new Error("Failed to create token");
const accessToken = jwt.sign(
{
authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: user.id,
tokenVersionId: tokenSession.id,
accessVersion: tokenSession.accessVersion
},
cfg.JWT_AUTH_SECRET,
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
);
const refreshToken = jwt.sign(
{
authTokenType: AuthTokenType.REFRESH_TOKEN,
userId: user.id,
tokenVersionId: tokenSession.id,
refreshVersion: tokenSession.refreshVersion
},
cfg.JWT_AUTH_SECRET,
{ expiresIn: cfg.JWT_REFRESH_LIFETIME }
);
return { access: accessToken, refresh: refreshToken };
};
/*
* Step 1 of login. To get server public key in exchange of client public key
*/
const loginGenServerPublicKey = async ({
email,
providerAuthToken,
clientPublicKey
}: TLoginGenServerPublicKeyDTO) => {
const user = await authDal.getUserEncKeyByEmail(email);
if (!user || (user && !user.isAccepted)) {
throw new Error("Failed to find user");
}
const cfg = getConfig();
if (
!user.authMethods?.includes(AuthMethod.EMAIL) &&
!isValidProviderAuthToken(email, cfg.JWT_AUTH_SECRET, providerAuthToken)
) {
throw new Error("Invalid authorization request");
}
const serverSrpKey = await generateSrpServerKey(user.salt, user.verifier);
const userEncKeys = await authDal.updateUserEncryptionByUserId(user.id, {
clientPublicKey,
serverPrivateKey: serverSrpKey.privateKey
});
if (!userEncKeys) throw new Error("Failed to update encryption key");
return { salt: userEncKeys.salt, serverPublicKey: serverSrpKey.pubKey };
};
/*
* Step 2 of login. Pass the client proof and with multi factor setup handle the required steps
*/
const loginExchangeClientProof = async ({
email,
clientProof,
providerAuthToken,
ip,
userAgent
}: TLoginClientProofDTO) => {
const user = await authDal.getUserEncKeyByEmail(email);
if (!user) throw new Error("Failed to find user");
const cfg = getConfig();
if (
!user.authMethods?.includes(AuthMethod.EMAIL) &&
!isValidProviderAuthToken(email, cfg.JWT_AUTH_SECRET, providerAuthToken)
) {
throw new Error("Invalid authorization request");
}
if (!user.serverPrivateKey || !user.clientPublicKey)
throw new Error("Failed to authenticate. Try again?");
const isValidClientProof = await srpCheckClientProof(
user.salt,
user.verifier,
user.serverPrivateKey,
user.clientPublicKey,
clientProof
);
if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?");
await authDal.updateUserEncryptionByUserId(user.id, {
serverPrivateKey: null,
clientPublicKey: null
});
// send multi factor auth token if they it enabled
if (user.isMfaEnabled) {
const mfaToken = jwt.sign(
{ authTokenType: AuthTokenType.MFA_TOKEN, userId: user.id },
cfg.JWT_AUTH_SECRET,
{ expiresIn: cfg.JWT_MFA_LIFETIME }
);
await sendUserMfaCode(user);
return { isMfaEnabled: true, token: mfaToken } as const;
}
const token = await generateUserTokens(user, ip, userAgent);
return { token, isMfaEnabled: false, user } as const;
};
/*
* Multi factor authentication re-send code, Get user id from token
* saved in frontend
*/
const resendMfaToken = async (userId: string) => {
const user = await authDal.getUserById(userId);
if (!user) return;
await sendUserMfaCode(user);
};
/*
* Multi factor authentication verification of code
* Third step of login in which user completes with mfa
* */
const verifyMfaToken = async ({ userId, mfaToken, ip, userAgent }: TVerifyMfaTokenDTO) => {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId,
code: mfaToken
});
const user = await authDal.getUserEncKeyByUserId(userId);
if (!user) throw new Error("Failed to authenticate user");
const token = await generateUserTokens(user, ip, userAgent);
return { token, user };
};
/*
* logout user by incrementing the version by 1 meaning any old session will become invalid
* as there number is behind
* */
const logout = async (userId: string, sessionId: string) => {
await tokenService.clearTokenSessionById(userId, sessionId);
};
return {
loginGenServerPublicKey,
loginExchangeClientProof,
logout,
resendMfaToken,
verifyMfaToken
};
};

View File

@@ -0,0 +1,20 @@
export type TLoginGenServerPublicKeyDTO = {
email: string;
clientPublicKey: string;
providerAuthToken?: string;
};
export type TLoginClientProofDTO = {
email: string;
clientProof: string;
providerAuthToken?: string;
ip: string;
userAgent: string;
};
export type TVerifyMfaTokenDTO = {
userId: string;
mfaToken: string;
ip: string;
userAgent: string;
};

View File

@@ -0,0 +1,257 @@
import jwt from "jsonwebtoken";
import { getConfig } from "@app/lib/config/env";
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTokenServiceFactory } from "../token/token-service";
import { TokenType } from "../token/token-types";
import { TAuthDalFactory } from "./auth-dal";
import {
TChangePasswordDTO,
TCreateBackupPrivateKeyDTO,
TResetPasswordViaBackupKeyDTO
} from "./auth-password-type";
import { AuthTokenType } from "./auth-signup-type";
type TAuthPasswordServiceFactoryDep = {
authDal: TAuthDalFactory;
tokenService: TTokenServiceFactory;
smtpService: TSmtpService;
};
export type TAuthPasswordFactory = ReturnType<typeof authPaswordServiceFactory>;
export const authPaswordServiceFactory = ({
authDal,
tokenService,
smtpService
}: TAuthPasswordServiceFactoryDep) => {
/*
* Pre setup for pass change with srp protocol
* Gets srp server user salt and server public key
*/
const generateServerPubKey = async (userId: string, clientPublicKey: string) => {
const user = await authDal.getUserEncKeyByUserId(userId);
if (!user) throw new Error("Failed to find user");
const serverSrpKey = await generateSrpServerKey(user.salt, user.verifier);
const userEncKeys = await authDal.updateUserEncryptionByUserId(user.id, {
clientPublicKey,
serverPrivateKey: serverSrpKey.privateKey
});
if (!userEncKeys) throw new Error("Failed to update encryption key");
return { salt: userEncKeys.salt, serverPublicKey: serverSrpKey.pubKey };
};
/*
* Change password to new pass
* */
const changePassword = async ({
userId,
clientProof,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
salt,
verifier,
tokenVersionId
}: TChangePasswordDTO) => {
const userEnc = await authDal.getUserEncKeyByUserId(userId);
if (!userEnc) throw new Error("Failed to find user");
await authDal.updateUserEncryptionByUserId(userEnc.userId, {
serverPrivateKey: null,
clientPublicKey: null
});
if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey)
throw new Error("Failed to authenticate. Try again?");
const isValidClientProof = await srpCheckClientProof(
userEnc.salt,
userEnc.verifier,
userEnc.serverPrivateKey,
userEnc.clientPublicKey,
clientProof
);
if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?");
await authDal.updateUserEncryptionByUserId(userId, {
encryptionVersion: 2,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
salt,
verifier,
serverPrivateKey: null,
clientPublicKey: null
});
if (tokenVersionId) {
await tokenService.clearTokenSessionById(userEnc.userId, tokenVersionId);
}
};
/*
* Email password reset flow via email. Step 1 send email
*/
const sendPasswordResetEmail = async (email: string) => {
const user = await authDal.getUserByEmail(email);
// ignore as user is not found to avoid an outside entity to identify infisical registered accounts
if (!user || (user && !user.isAccepted)) return;
const cfg = getConfig();
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_PASSWORD_RESET,
userId: user.id
});
await smtpService.sendMail({
template: SmtpTemplates.ResetPassword,
recipients: [email],
subjectLine: "Infisical password reset",
substitutions: {
email,
token,
callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : ""
}
});
};
/*
* Step 2 of reset password. Verify the token and inject a temp token to reset password
* */
const verifyPasswordResetEmail = async (email: string, code: string) => {
const cfg = getConfig();
const user = await authDal.getUserByEmail(email);
// ignore as user is not found to avoid an outside entity to identify infisical registered accounts
if (!user || (user && !user.isAccepted)) {
throw new Error("Failed email verification for pass reset");
}
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_PASSWORD_RESET,
userId: user.id,
code
});
const token = jwt.sign(
{
authTokenType: AuthTokenType.SIGNUP_TOKEN,
userId: user.id
},
cfg.JWT_AUTH_SECRET,
{ expiresIn: cfg.JWT_SIGNUP_LIFETIME }
);
return { token, user };
};
/*
* Reset password of a user via backup key
* */
const resetPasswordByBackupKey = async (
userId: string,
{
encryptedPrivateKey,
protectedKeyTag,
protectedKey,
protectedKeyIV,
salt,
verifier,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag
}: TResetPasswordViaBackupKeyDTO
) => {
await authDal.updateUserEncryptionByUserId(userId, {
encryptionVersion: 2,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
salt,
verifier
});
};
/*
* backup key creation to give user's their access back when lost their password
* this also needs to do the generateServerPubKey function to be executed first
* then only client proof can be verified
* */
const createBackupPrivateKey = async ({
clientProof,
encryptedPrivateKey,
salt,
verifier,
iv,
tag,
userId
}: TCreateBackupPrivateKeyDTO) => {
const user = await authDal.getUserEncKeyByUserId(userId);
if (!user || (user && !user.isAccepted)) {
throw new Error("Failed to find user");
}
if (!user.clientPublicKey || !user.serverPrivateKey)
throw new Error("failed to create backup key");
const isValidClientProff = await srpCheckClientProof(
user.salt,
user.verifier,
user.serverPrivateKey,
user.clientPublicKey,
clientProof
);
if (!isValidClientProff) throw new Error("failed to create backup key");
const backup = await authDal.transaction(async (tx) => {
const backupKey = await authDal.upsertBackupKey(user.id, {
encryptedPrivateKey,
iv,
tag,
salt,
verifier
});
await authDal.updateUserEncryptionByUserId(
user.id,
{
serverPrivateKey: null,
clientPublicKey: null
},
tx
);
return backupKey;
});
return backup;
};
/*
* Return user back up
* */
const getBackupPrivateKeyOfUser = async (userId: string) => {
const user = await authDal.getUserEncKeyByUserId(userId);
if (!user || (user && !user.isAccepted)) {
throw new Error("Failed to find user");
}
const backupKey = await authDal.getBackupPrivateKeyByUserId(userId);
if (!backupKey) throw new Error("Failed to find user backup key");
return backupKey;
};
return {
generateServerPubKey,
changePassword,
resetPasswordByBackupKey,
sendPasswordResetEmail,
verifyPasswordResetEmail,
createBackupPrivateKey,
getBackupPrivateKeyOfUser
};
};

View File

@@ -0,0 +1,35 @@
export type TChangePasswordDTO = {
userId: string;
clientProof: string;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
salt: string;
verifier: string;
tokenVersionId?: string;
};
export type TResetPasswordViaBackupKeyDTO = {
userId: string;
protectedKey: string;
protectedKeyIV: string;
protectedKeyTag: string;
encryptedPrivateKey: string;
encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string;
salt: string;
verifier: string;
};
export type TCreateBackupPrivateKeyDTO = {
userId: string;
clientProof: string;
encryptedPrivateKey: string;
salt: string;
iv: string;
tag: string;
verifier: string;
};

View File

@@ -0,0 +1,169 @@
import { AuthMethod } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { isDisposableEmail } from "@app/lib/validator";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTokenServiceFactory } from "../token/token-service";
import { TokenType } from "../token/token-types";
import { TAuthDalFactory } from "./auth-dal";
import { AuthTokenType, TCompleteAccountSignupDTO } from "./auth-signup-type";
type TAuthSignupDep = {
authDal: TAuthDalFactory;
tokenService: TTokenServiceFactory;
smtpService: TSmtpService;
};
export type TAuthSignupFactory = ReturnType<typeof authSignupServiceFactory>;
export const authSignupServiceFactory = ({
authDal,
tokenService,
smtpService
}: TAuthSignupDep) => {
// first step of signup. create user and send email
const beginEmailSignupProcess = async (email: string) => {
const isEmailInvalid = await isDisposableEmail(email);
if (isEmailInvalid) {
throw new Error("Provided a disposable email");
}
let user = await authDal.getUserByEmail(email);
if (user && user.isAccepted) {
// TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues
throw new Error("Failed to send verification code for complete account");
}
if (!user) {
user = await authDal.createUser(email, { authMethods: [AuthMethod.EMAIL] });
}
if (!user) throw new Error("Failed to create user");
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
userId: user.id
});
await smtpService.sendMail({
template: SmtpTemplates.EmailVerification,
subjectLine: "Infisical confirmation code",
recipients: [email],
substitutions: {
code: token
}
});
};
const verifyEmailSignup = async (email: string, code: string) => {
const user = await authDal.getUserByEmail(email);
if (!user || (user && user.isAccepted)) {
// TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues
throw new Error("Failed to send verification code for complete account");
}
const appCfg = getConfig();
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
userId: user.id,
code
});
// generate jwt token this is a temporary token
const jwtToken = tokenService.createJwtToken(
{
authTokenType: AuthTokenType.SIGNUP_TOKEN,
userId: user.id.toString()
},
appCfg.JWT_AUTH_SECRET,
{ expiresIn: appCfg.JWT_SIGNUP_LIFETIME }
);
return { user, token: jwtToken };
};
const completeEmailAccountSignup = async ({
email,
firstName,
lastName,
// providerAuthToken,
salt,
verifier,
publicKey,
protectedKey,
protectedKeyIV,
protectedKeyTag,
// organizationName,
// attributionSource,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
ip,
userAgent
}: TCompleteAccountSignupDTO) => {
const user = await authDal.getUserByEmail(email);
if (!user || (user && user.isAccepted)) {
throw new Error("Failed to complete account for complete user");
}
const updateduser = await authDal.transaction(async (tx) => {
const us = await authDal.updateUserById(
user.id,
{ firstName, lastName, isAccepted: true },
tx
);
if (!us) throw new Error("User not found");
const userEncKey = await authDal.upsertUserEncryptionKey(
us.id,
{
salt,
verifier,
publicKey,
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
},
tx
);
return { info: us, key: userEncKey };
});
// TODO(akhilmhdh-pg): add default org memberships
const tokenSession = await tokenService.getUserTokenSession({
userAgent,
ip,
userId: updateduser.info.id
});
if (!tokenSession) throw new Error("Failed to create token");
const appCfg = getConfig();
const accessToken = tokenService.createJwtToken(
{
authTokenType: AuthTokenType.ACCESS_TOKEN,
userId: updateduser.info.id,
tokenVersionId: tokenSession.id,
accessVersion: tokenSession.accessVersion
},
appCfg.JWT_AUTH_SECRET,
{ expiresIn: appCfg.JWT_SIGNUP_LIFETIME }
);
const refreshToken = tokenService.createJwtToken(
{
authTokenType: AuthTokenType.REFRESH_TOKEN,
userId: updateduser.info.id,
tokenVersionId: tokenSession.id,
refreshVersion: tokenSession.refreshVersion
},
appCfg.JWT_AUTH_SECRET,
{ expiresIn: appCfg.JWT_SIGNUP_LIFETIME }
);
return { user: updateduser.info, accessToken, refreshToken };
};
return {
beginEmailSignupProcess,
verifyEmailSignup,
completeEmailAccountSignup
};
};

View File

@@ -0,0 +1,38 @@
export enum AuthTokenType {
ACCESS_TOKEN = "accessToken",
REFRESH_TOKEN = "refreshToken",
SIGNUP_TOKEN = "signupToken", // TODO: remove in favor of claim
MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim
PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim
API_KEY = "apiKey",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
SERVICE_REFRESH_TOKEN = "serviceRefreshToken"
}
export enum AuthMode {
JWT = "jwt",
SERVICE_TOKEN = "serviceToken",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
API_KEY = "apiKey",
API_KEY_V2 = "apiKeyV2"
}
export type TCompleteAccountSignupDTO = {
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;
providerAuthToken?: string | null;
attributionSource?: string | undefined;
ip: string;
userAgent: string;
};

View File

@@ -0,0 +1,78 @@
import fs from "node:fs/promises";
import path from "node:path";
import handlebars from "handlebars";
import { createTransport } from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import { logger } from "@app/lib/logger";
export type TSmtpConfig = SMTPTransport.Options;
export type TSmtpSendMail = {
template: SmtpTemplates;
subjectLine: string;
recipients: string[];
substitutions: unknown;
};
export type TSmtpService = ReturnType<typeof smtpServiceFactory>;
export enum SmtpTemplates {
EmailVerification = "emailVerification.handlebars",
EmailMfa = "emailMfa.handlebars",
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
NewDeviceJoin = "newDevice.handlebars",
OrgInvite = "organizationInvitation.handlebars",
ResetPassword = "passwordReset.handlebars",
SecretLeakIncident = "secretLeakIncident.handlebars",
WorkspaceInvite = "workspaceInvitation.handlebars"
}
export enum SmtpHost {
Sendgrid = "smtp.sendgrid.net",
Mailgun = "smtp.mailgun.org",
SocketLabs = "smtp.sockerlabs.com",
Zohomail = "smtp.zoho.com",
Gmail = "smtp.gmail.com",
Office365 = "smtp.office365.com"
}
const getTlsOption = (host?: SmtpHost | string, secure?: boolean) => {
if (!secure) return { secure: false };
if (!host) return { secure: true };
if (host === SmtpHost.Sendgrid) {
return { requireTLS: true };
}
if (host.includes("amazonaws.com")) {
return { tls: { ciphers: "TLSv1.2" } };
}
return { requireTLS: true, tls: { ciphers: "TLSv1.2" } };
};
export const smtpServiceFactory = (cfg: TSmtpConfig) => {
const smtp = createTransport({ ...cfg, ...getTlsOption(cfg.host, cfg.secure) });
const isSmtpOn = Boolean(cfg.host);
const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => {
const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8");
const temp = handlebars.compile(html);
const htmlToSend = temp(substitutions);
if (isSmtpOn) {
await smtp.sendMail({
from: cfg.from,
to: recipients.join(", "),
subject: subjectLine,
html: htmlToSend
});
} else {
logger.info("SMTP is not configured. Outputting it in terminal");
logger.info({
from: cfg.from,
to: recipients.join(", "),
subject: subjectLine,
html: htmlToSend
});
}
};
return { sendMail };
};

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>MFA Code</title>
</head>
<body>
<h2>Infisical</h2>
<h2>Sign in attempt requires further verification</h2>
<p>Your MFA code is below — enter it where you started signing in to Infisical.</p>
<h2>{{code}}</h2>
<p>The MFA code will be valid for 2 minutes.</p>
<p>Not you? Contact Infisical or your administrator immediately.</p>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Code</title>
</head>
<body>
<h2>Confirm your email address</h2>
<p>Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.</p>
<h1>{{code}}</h1>
<p>Questions about setting up Infisical? Email us at support@infisical.com</p>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Incident alert: secrets potentially leaked</title>
</head>
<body>
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo</h3>
<p><a href="https://app.infisical.com/secret-scanning"><strong>View leaked secrets</strong></a></p>
<p>If these are production secrets, please rotate them immediately.</p>
<p>Once you have taken action, be sure to update the status of the risk in your <a
href="https://app.infisical.com/">Infisical
dashboard</a>.</p>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Successful login for {{email}} from new device</title>
</head>
<body>
<h2>Infisical</h2>
<p>We're verifying a recent login for {{email}}:</p>
<p><strong>Timestamp</strong>: {{timestamp}}</p>
<p><strong>IP address</strong>: {{ip}}</p>
<p><strong>User agent</strong>: {{userAgent}}</p>
<p>If you believe that this login is suspicious, please contact Infisical or reset your password immediately.</p>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Organization Invitation</title>
</head>
<body>
<h2>Join your organization on Infisical</h2>
<p>{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}</p>
<a href="{{callback_url}}?token={{token}}&to={{email}}&organization_id={{organizationId}}">Join now</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Account Recovery</title>
</head>
<body>
<h2>Reset your password</h2>
<p>Someone requested a password reset.</p>
<a href="{{callback_url}}?token={{token}}&to={{email}}">Reset password</a>
<p>If you didn't initiate this request, please contact us immediately at team@infisical.com</p>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Incident alert: secret leaked</title>
</head>
<body>
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push</h3>
<p><a href="https://app.infisical.com/secret-scanning"><strong>View leaked secrets</strong></a></p>
<p>You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed
by {{pusher_name}} ({{pusher_email}}). If
these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as comment
in the given programming. This will prevent future notifications from being sent out for those secret(s).</p>
<p>If these are production secrets, please rotate them immediately.</p>
<p>Once you have taken action, be sure to update the status of the risk in your <a
href="https://app.infisical.com/">Infisical
dashboard</a>.</p>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Project Invitation</title>
</head>
<body>
<h2>Join your team on Infisical</h2>
<p>{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}</p>
<a href="{{callback_url}}">Join now</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
</body>
</html>

View File

@@ -0,0 +1,98 @@
import { TDbClient } from "@app/db";
import { TableName, TToken } from "@app/db/schemas";
import { TTokenSession } from "@app/db/schemas/token-session";
import {
TDeleteTokenForUserDalDTO,
TGetTokenForUserDalDTO,
TUpsertTokenForUserDalDTO
} from "./token-types";
export type TTokenDalConfig = {};
export type TTokenDalFactory = ReturnType<typeof tokenDalFactory>;
export const tokenDalFactory = (db: TDbClient) => {
const upsertTokenForUser = async ({
tokenHash,
expiresAt,
userId,
type,
triesLeft
}: TUpsertTokenForUserDalDTO): Promise<TToken | undefined> => {
const token = await db.transaction(async (tx) => {
await tx(TableName.AuthTokens).where({ userId, type }).delete().returning("*");
const [newToken] = await tx(TableName.AuthTokens)
.insert({ tokenHash, expiresAt: expiresAt.toUTCString(), type, userId, triesLeft })
.returning("*");
return newToken;
});
return token;
};
const getTokenForUser = async ({
userId,
type
}: TGetTokenForUserDalDTO): Promise<TToken | undefined> =>
db(TableName.AuthTokens).where({ userId, type }).first();
const deleteTokenForUser = async ({
userId,
type
}: TDeleteTokenForUserDalDTO): Promise<TToken[] | undefined> =>
db(TableName.AuthTokens).where({ userId, type }).delete().returning("*");
const decrementTriesField = async ({
userId,
type
}: TDeleteTokenForUserDalDTO): Promise<void> => {
await db(TableName.AuthTokens).where({ userId, type }).decrement("triesLeft", 1);
};
const getTokenSession = async (
userId: string,
ip: string,
userAgent: string
): Promise<TTokenSession | undefined> =>
db(TableName.AuthTokenSession).where({ userId, ip, userAgent }).first();
const insertTokenSession = async (
userId: string,
ip: string,
userAgent: string
): Promise<TTokenSession | undefined> => {
const [session] = await db(TableName.AuthTokenSession)
.insert({
userId,
ip,
userAgent,
accessVersion: 1,
refreshVersion: 1,
lastUsed: new Date().toUTCString()
})
.returning("*");
return session;
};
const incrementVersion = async (
userId: string,
sessionId: string
): Promise<TTokenSession | undefined> => {
const [session] = await db(TableName.AuthTokenSession)
.where({ userId, id: sessionId })
.increment("accessVersion", 1)
.increment("refreshVersion", 1)
.returning("*");
return session;
};
return {
upsertTokenForUser,
getTokenForUser,
deleteTokenForUser,
decrementTriesField,
getTokenSession,
insertTokenSession,
incrementVersion
};
};

View File

@@ -0,0 +1,134 @@
import crypto from "node:crypto";
import bcrypt from "bcrypt";
import jwt, { SignOptions } from "jsonwebtoken";
import { TToken } from "@app/db/schemas";
import { TTokenSession } from "@app/db/schemas/token-session";
import { getConfig } from "@app/lib/config/env";
import { TTokenDalFactory } from "./token-dal";
import {
TCreateTokenForUserDTO,
TIssueAuthTokenDTO,
TokenType,
TValidateTokenForUserDTO
} from "./token-types";
type TTokenServiceFactoryDep = {
tokenDal: TTokenDalFactory;
// adjust the expiry from env through here
};
export type TTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
export const getTokenConfig = (tokenType: TokenType) => {
// generate random token based on specified token use-case
// type [type]
switch (tokenType) {
case TokenType.TOKEN_EMAIL_CONFIRMATION: {
// generate random 6-digit code
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
const expiresAt = new Date(new Date().getTime() + 86400000);
return { token, expiresAt };
}
case TokenType.TOKEN_EMAIL_MFA: {
// generate random 6-digit code
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
const triesLeft = 5;
const expiresAt = new Date(new Date().getTime() + 300000);
return { token, triesLeft, expiresAt };
}
case TokenType.TOKEN_EMAIL_ORG_INVITATION: {
// generate random hex
const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date(new Date().getTime() + 259200000);
return { token, expiresAt };
}
case TokenType.TOKEN_EMAIL_PASSWORD_RESET: {
// generate random hex
const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date(new Date().getTime() + 86400000);
return { token, expiresAt };
}
default: {
const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date();
return { token, expiresAt };
}
}
};
export const tokenServiceFactory = ({ tokenDal }: TTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig();
const tokenHash = await bcrypt.hash(token, appCfg.SALT_ROUNDS);
await tokenDal.upsertTokenForUser({
userId,
type,
expiresAt: tkCfg.expiresAt,
tokenHash,
triesLeft: tkCfg?.triesLeft
});
return token;
};
const validateTokenForUser = async ({
type,
userId,
code
}: TValidateTokenForUserDTO): Promise<TToken | undefined> => {
const token = await tokenDal.getTokenForUser({ type, userId });
// validate token
if (!token) throw new Error("Failed to find token");
if (token?.expiresAt && new Date(token.expiresAt) < new Date()) {
await tokenDal.deleteTokenForUser({ type, userId });
throw new Error("Token expired. Please try again");
}
const isValidToken = await bcrypt.compare(code, token.tokenHash);
if (!isValidToken) {
if (token?.triesLeft) {
if (token.triesLeft === 1) {
await tokenDal.deleteTokenForUser({ type, userId });
} else {
await tokenDal.decrementTriesField({ type, userId });
}
}
throw new Error("Invalid token");
}
const deletedToken = await tokenDal.deleteTokenForUser({ type, userId });
return deletedToken?.[0];
};
const getUserTokenSession = async ({
userId,
ip,
userAgent
}: TIssueAuthTokenDTO): Promise<TTokenSession | undefined> => {
let session = await tokenDal.getTokenSession(userId, ip, userAgent);
if (!session) {
session = await tokenDal.insertTokenSession(userId, ip, userAgent);
}
return session;
};
const clearTokenSessionById = async (
userId: string,
sessionId: string
): Promise<TTokenSession | undefined> => tokenDal.incrementVersion(userId, sessionId);
const createJwtToken = (
payload: string | Buffer | object,
secret: string,
options?: SignOptions
) => jwt.sign(payload, secret, options);
return {
createTokenForUser,
validateTokenForUser,
createJwtToken,
getUserTokenSession,
clearTokenSessionById
};
};

View File

@@ -0,0 +1,41 @@
export enum TokenType {
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
TOKEN_EMAIL_MFA = "emailMfa",
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset"
}
export type TCreateTokenForUserDTO = {
type: TokenType;
userId: string;
};
export type TValidateTokenForUserDTO = {
type: TokenType;
code: string;
userId: string;
};
export type TUpsertTokenForUserDalDTO = {
type: TokenType;
expiresAt: Date;
userId: string;
tokenHash: string;
triesLeft?: number;
};
export type TGetTokenForUserDalDTO = {
userId: string;
type: TokenType;
};
export type TDeleteTokenForUserDalDTO = {
userId: string;
type: TokenType;
};
export type TIssueAuthTokenDTO = {
userId: string;
ip: string;
userAgent: string;
};

58
docker-compose.pg.yml Normal file
View File

@@ -0,0 +1,58 @@
version: "3.9"
services:
nginx:
container_name: infisical-dev-nginx
image: nginx
restart: always
ports:
- 8080:80
volumes:
- ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
db:
image: postgres:14-alpine
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: infisical
POSTGRES_USER: infisical
POSTGRES_DB: infisical
backend:
build:
context: ./backend-pg
dockerfile: Dockerfile.dev
depends_on:
- db
env_file:
- .env
environment:
- DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable
volumes:
- ./backend-pg/src:/app/src
frontend:
container_name: infisical-dev-frontend
restart: unless-stopped
depends_on:
- backend
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files
- ./frontend/public:/app/public
env_file: .env
environment:
- NEXT_PUBLIC_ENV=development
- INFISICAL_TELEMETRY_ENABLED=false
volumes:
postgres-data:
driver: local

View File

@@ -72,9 +72,9 @@ export const ServerConfigProvider = ({ children }: Props): JSX.Element => {
export const useServerConfig = () => {
const ctx = useContext(ServerConfigContext);
if (!ctx) {
throw new Error("useServerConfig has to be used within <UserContext.Provider>");
}
// if (!ctx) {
// throw new Error("useServerConfig has to be used within <UserContext.Provider>");
// }
return ctx;
return ctx || { config: { allowSignUp: true } };
};

View File

@@ -121,7 +121,7 @@ export const verifySignupInvite = async (details: VerifySignupInviteDTO) => {
export const useSendVerificationEmail = () => {
return useMutation({
mutationFn: async ({ email }: { email: string }) => {
const { data } = await apiRequest.post("/api/v1/signup/email/signup", {
const { data } = await apiRequest.post("/api/v3/signup/email/signup", {
email
});
@@ -133,7 +133,7 @@ export const useSendVerificationEmail = () => {
export const useVerifyEmailVerificationCode = () => {
return useMutation({
mutationFn: async ({ email, code }: { email: string; code: string }) => {
const { data } = await apiRequest.post("/api/v1/signup/email/verify", {
const { data } = await apiRequest.post("/api/v3/signup/email/verify", {
email,
code
});

View File

@@ -82,14 +82,13 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
publicPaths.includes(`/${appProps.router.pathname.split("/")[1]}`) ||
!Component.requireAuth
) {
// TODO(akhilmhdh): bring back server config later
return (
<QueryClientProvider client={queryClient}>
<NotificationProvider>
<ServerConfigProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</ServerConfigProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</NotificationProvider>
</QueryClientProvider>
);