mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 14:27:23 +00:00
feat: initial implementation for totp authenticator
This commit is contained in:
@@ -17,6 +17,7 @@ import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { LoginMethod } from "../super-admin/super-admin-types";
|
||||
import { TTotpServiceFactory } from "../totp/totp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
|
||||
import {
|
||||
@@ -26,13 +27,14 @@ import {
|
||||
TOauthTokenExchangeDTO,
|
||||
TVerifyMfaTokenDTO
|
||||
} from "./auth-login-type";
|
||||
import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type";
|
||||
import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "./auth-type";
|
||||
|
||||
type TAuthLoginServiceFactoryDep = {
|
||||
userDAL: TUserDALFactory;
|
||||
orgDAL: TOrgDALFactory;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
smtpService: TSmtpService;
|
||||
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
|
||||
};
|
||||
|
||||
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
|
||||
@@ -40,7 +42,8 @@ export const authLoginServiceFactory = ({
|
||||
userDAL,
|
||||
tokenService,
|
||||
smtpService,
|
||||
orgDAL
|
||||
orgDAL,
|
||||
totpService
|
||||
}: TAuthLoginServiceFactoryDep) => {
|
||||
/*
|
||||
* Private
|
||||
@@ -100,7 +103,8 @@ export const authLoginServiceFactory = ({
|
||||
userAgent,
|
||||
organizationId,
|
||||
authMethod,
|
||||
isMfaVerified
|
||||
isMfaVerified,
|
||||
mfaMethod
|
||||
}: {
|
||||
user: TUsers;
|
||||
ip: string;
|
||||
@@ -108,6 +112,7 @@ export const authLoginServiceFactory = ({
|
||||
organizationId?: string;
|
||||
authMethod: AuthMethod;
|
||||
isMfaVerified?: boolean;
|
||||
mfaMethod?: MfaMethod;
|
||||
}) => {
|
||||
const cfg = getConfig();
|
||||
await updateUserDeviceSession(user, ip, userAgent);
|
||||
@@ -126,7 +131,8 @@ export const authLoginServiceFactory = ({
|
||||
tokenVersionId: tokenSession.id,
|
||||
accessVersion: tokenSession.accessVersion,
|
||||
organizationId,
|
||||
isMfaVerified
|
||||
isMfaVerified,
|
||||
mfaMethod
|
||||
},
|
||||
cfg.AUTH_SECRET,
|
||||
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
|
||||
@@ -140,7 +146,8 @@ export const authLoginServiceFactory = ({
|
||||
tokenVersionId: tokenSession.id,
|
||||
refreshVersion: tokenSession.refreshVersion,
|
||||
organizationId,
|
||||
isMfaVerified
|
||||
isMfaVerified,
|
||||
mfaMethod
|
||||
},
|
||||
cfg.AUTH_SECRET,
|
||||
{ expiresIn: cfg.JWT_REFRESH_LIFETIME }
|
||||
@@ -353,8 +360,12 @@ export const authLoginServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
// send multi factor auth token if they it enabled
|
||||
if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) {
|
||||
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
|
||||
const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod : undefined;
|
||||
const userMfaMethod = user.isMfaEnabled ? user.selectedMfaMethod : undefined;
|
||||
const mfaMethod = orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL;
|
||||
|
||||
if (shouldCheckMfa && (!decodedToken.isMfaVerified || decodedToken.mfaMethod !== mfaMethod)) {
|
||||
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
|
||||
|
||||
const mfaToken = jwt.sign(
|
||||
@@ -369,12 +380,14 @@ export const authLoginServiceFactory = ({
|
||||
}
|
||||
);
|
||||
|
||||
await sendUserMfaCode({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
if (mfaMethod === MfaMethod.EMAIL && user.email) {
|
||||
await sendUserMfaCode({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
}
|
||||
|
||||
return { isMfaEnabled: true, mfa: mfaToken } as const;
|
||||
return { isMfaEnabled: true, mfa: mfaToken, mfaMethod } as const;
|
||||
}
|
||||
|
||||
const tokens = await generateUserTokens({
|
||||
@@ -458,17 +471,39 @@ export const authLoginServiceFactory = ({
|
||||
* Multi factor authentication verification of code
|
||||
* Third step of login in which user completes with mfa
|
||||
* */
|
||||
const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => {
|
||||
const verifyMfaToken = async ({
|
||||
userId,
|
||||
mfaToken,
|
||||
mfaMethod,
|
||||
mfaJwtToken,
|
||||
ip,
|
||||
userAgent,
|
||||
orgId
|
||||
}: TVerifyMfaTokenDTO) => {
|
||||
const appCfg = getConfig();
|
||||
const user = await userDAL.findById(userId);
|
||||
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
|
||||
|
||||
try {
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId,
|
||||
code: mfaToken
|
||||
});
|
||||
if (mfaMethod === MfaMethod.EMAIL) {
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId,
|
||||
code: mfaToken
|
||||
});
|
||||
} else if (mfaMethod === MfaMethod.TOTP) {
|
||||
if (mfaToken.length === 6) {
|
||||
await totpService.verifyUserTotp({
|
||||
userId,
|
||||
totp: mfaToken
|
||||
});
|
||||
} else {
|
||||
await totpService.verifyWithUserRecoveryCode({
|
||||
userId,
|
||||
recoveryCode: mfaToken
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const updatedUser = await processFailedMfaAttempt(userId);
|
||||
if (updatedUser.isLocked) {
|
||||
@@ -513,7 +548,8 @@ export const authLoginServiceFactory = ({
|
||||
userAgent,
|
||||
organizationId: orgId,
|
||||
authMethod: decodedToken.authMethod,
|
||||
isMfaVerified: true
|
||||
isMfaVerified: true,
|
||||
mfaMethod
|
||||
});
|
||||
|
||||
return { token, user: userEnc };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthMethod } from "./auth-type";
|
||||
import { AuthMethod, MfaMethod } from "./auth-type";
|
||||
|
||||
export type TLoginGenServerPublicKeyDTO = {
|
||||
email: string;
|
||||
@@ -19,6 +19,7 @@ export type TLoginClientProofDTO = {
|
||||
export type TVerifyMfaTokenDTO = {
|
||||
userId: string;
|
||||
mfaToken: string;
|
||||
mfaMethod: MfaMethod;
|
||||
mfaJwtToken: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
|
||||
@@ -53,6 +53,7 @@ export type AuthModeJwtTokenPayload = {
|
||||
accessVersion: number;
|
||||
organizationId?: string;
|
||||
isMfaVerified?: boolean;
|
||||
mfaMethod?: MfaMethod;
|
||||
};
|
||||
|
||||
export type AuthModeMfaJwtTokenPayload = {
|
||||
@@ -85,3 +86,8 @@ export type AuthModeProviderSignUpTokenPayload = {
|
||||
authTokenType: AuthTokenType.SIGNUP_TOKEN;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export enum MfaMethod {
|
||||
EMAIL = "email",
|
||||
TOTP = "totp"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TTotpConfigDALFactory = ReturnType<typeof totpConfigDALFactory>;
|
||||
|
||||
export const totpConfigDALFactory = (db: TDbClient) => {
|
||||
const totpConfigDal = ormify(db, TableName.TotpConfig);
|
||||
|
||||
return totpConfigDal;
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TTotpConfigDALFactory } from "./totp-config-dal";
|
||||
import {
|
||||
TDeleteUserTotpConfigDTO,
|
||||
TGetUserTotpConfigDTO,
|
||||
TRegisterUserTotpDTO,
|
||||
TVerifyUserTotpConfigDTO,
|
||||
TVerifyUserTotpDTO,
|
||||
TVerifyWithUserRecoveryCodeDTO
|
||||
} from "./totp-types";
|
||||
|
||||
type TTotpServiceFactoryDep = {
|
||||
userDAL: TUserDALFactory;
|
||||
totpConfigDAL: TTotpConfigDALFactory;
|
||||
kmsService: TKmsServiceFactory;
|
||||
};
|
||||
|
||||
export type TTotpServiceFactory = ReturnType<typeof totpServiceFactory>;
|
||||
|
||||
export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotpServiceFactoryDep) => {
|
||||
const getUserTotpConfig = async ({ userId }: TGetUserTotpConfigDTO) => {
|
||||
const totpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: true
|
||||
});
|
||||
|
||||
if (!totpConfig) {
|
||||
throw new NotFoundError({
|
||||
message: "TOTP configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
|
||||
|
||||
return {
|
||||
isVerified: totpConfig.isVerified,
|
||||
recoveryCodes
|
||||
};
|
||||
};
|
||||
|
||||
const registerUserTotp = async ({ userId }: TRegisterUserTotpDTO) => {
|
||||
const totpConfig = await totpConfigDAL.transaction(async (tx) => {
|
||||
const verifiedTotpConfig = await totpConfigDAL.findOne(
|
||||
{
|
||||
userId,
|
||||
isVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (verifiedTotpConfig) {
|
||||
throw new BadRequestError({
|
||||
message: "TOTP configuration for user already exists"
|
||||
});
|
||||
}
|
||||
|
||||
const unverifiedTotpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: false
|
||||
});
|
||||
|
||||
if (unverifiedTotpConfig) {
|
||||
return unverifiedTotpConfig;
|
||||
}
|
||||
|
||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||
|
||||
// create new TOTP configuration
|
||||
const secret = authenticator.generateSecret();
|
||||
const encryptedSecret = encryptWithRoot(Buffer.from(secret));
|
||||
const recoveryCodes = Array.from({ length: 10 }).map(() => String(crypto.randomInt(10 ** 7, 10 ** 8 - 1)));
|
||||
const encryptedRecoveryCodes = encryptWithRoot(Buffer.from(recoveryCodes.join(",")));
|
||||
const newTotpConfig = await totpConfigDAL.create({
|
||||
userId,
|
||||
encryptedRecoveryCodes,
|
||||
encryptedSecret
|
||||
});
|
||||
|
||||
return newTotpConfig;
|
||||
});
|
||||
|
||||
const user = await userDAL.findById(userId);
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
|
||||
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
|
||||
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
|
||||
const otpUrl = authenticator.keyuri(user.username, "Infisical", secret);
|
||||
|
||||
return {
|
||||
otpUrl,
|
||||
recoveryCodes
|
||||
};
|
||||
};
|
||||
|
||||
const verifyUserTotpConfig = async ({ userId, totp }: TVerifyUserTotpConfigDTO) => {
|
||||
const totpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: false
|
||||
});
|
||||
|
||||
if (!totpConfig) {
|
||||
throw new NotFoundError({
|
||||
message: "TOTP configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
|
||||
const isValid = authenticator.verify({
|
||||
token: totp,
|
||||
secret
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
await totpConfigDAL.updateById(totpConfig.id, {
|
||||
isVerified: true
|
||||
});
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid TOTP token"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => {
|
||||
const totpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: true
|
||||
});
|
||||
|
||||
if (!totpConfig) {
|
||||
throw new NotFoundError({
|
||||
message: "TOTP configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
|
||||
const isValid = authenticator.verify({
|
||||
token: totp,
|
||||
secret
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Invalid TOTP"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const verifyWithUserRecoveryCode = async ({ userId, recoveryCode }: TVerifyWithUserRecoveryCodeDTO) => {
|
||||
const totpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: true
|
||||
});
|
||||
|
||||
if (!totpConfig) {
|
||||
throw new NotFoundError({
|
||||
message: "TOTP configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||
|
||||
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
|
||||
const matchingCode = recoveryCodes.find((code) => recoveryCode === code);
|
||||
if (!matchingCode) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Invalid TOTP recovery code"
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRecoveryCodes = recoveryCodes.filter((code) => code !== matchingCode);
|
||||
const encryptedRecoveryCodes = encryptWithRoot(Buffer.from(updatedRecoveryCodes.join(",")));
|
||||
await totpConfigDAL.updateById(totpConfig.id, {
|
||||
encryptedRecoveryCodes
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUserTotpConfig = async ({ userId }: TDeleteUserTotpConfigDTO) => {
|
||||
const totpConfig = await totpConfigDAL.findOne({
|
||||
userId,
|
||||
isVerified: true
|
||||
});
|
||||
|
||||
if (!totpConfig) {
|
||||
throw new NotFoundError({
|
||||
message: "TOTP configuration not found"
|
||||
});
|
||||
}
|
||||
|
||||
await totpConfigDAL.deleteById(totpConfig.id);
|
||||
};
|
||||
|
||||
return {
|
||||
registerUserTotp,
|
||||
verifyUserTotpConfig,
|
||||
getUserTotpConfig,
|
||||
verifyUserTotp,
|
||||
verifyWithUserRecoveryCode,
|
||||
deleteUserTotpConfig
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export type TRegisterUserTotpDTO = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type TVerifyUserTotpConfigDTO = {
|
||||
userId: string;
|
||||
totp: string;
|
||||
};
|
||||
|
||||
export type TGetUserTotpConfigDTO = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type TVerifyUserTotpDTO = {
|
||||
userId: string;
|
||||
totp: string;
|
||||
};
|
||||
|
||||
export type TVerifyWithUserRecoveryCodeDTO = {
|
||||
userId: string;
|
||||
recoveryCode: string;
|
||||
};
|
||||
|
||||
export type TDeleteUserTotpConfigDTO = {
|
||||
userId: string;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import { AuthMethod } from "../auth/auth-type";
|
||||
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TUserDALFactory } from "./user-dal";
|
||||
import { TListUserGroupsDTO } from "./user-types";
|
||||
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
|
||||
|
||||
type TUserServiceFactoryDep = {
|
||||
userDAL: Pick<
|
||||
@@ -171,15 +171,24 @@ export const userServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => {
|
||||
const updateUserMfa = async ({ userId, isMfaEnabled, selectedMfaMethod }: TUpdateUserMfaDTO) => {
|
||||
const user = await userDAL.findById(userId);
|
||||
|
||||
if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" });
|
||||
|
||||
let mfaMethods;
|
||||
if (isMfaEnabled === undefined) {
|
||||
mfaMethods = undefined;
|
||||
} else {
|
||||
mfaMethods = isMfaEnabled ? ["email"] : [];
|
||||
}
|
||||
|
||||
const updatedUser = await userDAL.updateById(userId, {
|
||||
isMfaEnabled,
|
||||
mfaMethods: isMfaEnabled ? ["email"] : []
|
||||
mfaMethods,
|
||||
selectedMfaMethod
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
@@ -327,7 +336,7 @@ export const userServiceFactory = ({
|
||||
return {
|
||||
sendEmailVerificationCode,
|
||||
verifyEmailVerificationCode,
|
||||
toggleUserMfa,
|
||||
updateUserMfa,
|
||||
updateUserName,
|
||||
updateAuthMethods,
|
||||
deleteUser,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TOrgPermission } from "@app/lib/types";
|
||||
|
||||
import { MfaMethod } from "../auth/auth-type";
|
||||
|
||||
export type TListUserGroupsDTO = {
|
||||
username: string;
|
||||
} & Omit<TOrgPermission, "orgId">;
|
||||
@@ -8,3 +10,9 @@ export enum UserEncryption {
|
||||
V1 = 1,
|
||||
V2 = 2
|
||||
}
|
||||
|
||||
export type TUpdateUserMfaDTO = {
|
||||
userId: string;
|
||||
isMfaEnabled?: boolean;
|
||||
selectedMfaMethod?: MfaMethod;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user