feat: added user-locking on mfa failure

This commit is contained in:
Sheen Capadngan
2024-05-22 21:55:56 +08:00
parent 880289217e
commit 41d72d5dc6
11 changed files with 240 additions and 12 deletions
@@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
type TAuthTokenServiceFactoryDep = {
tokenDAL: TTokenDALFactory;
userDAL: Pick<TUserDALFactory, "findById">;
userDAL: Pick<TUserDALFactory, "findById" | "transaction">;
};
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
export const getTokenConfig = (tokenType: TokenType) => {
@@ -53,6 +54,11 @@ export const getTokenConfig = (tokenType: TokenType) => {
const expiresAt = new Date(new Date().getTime() + 86400000);
return { token, expiresAt };
}
case TokenType.TOKEN_USER_UNLOCK: {
const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date(new Date().getTime() + 259200000);
return { token, expiresAt };
}
default: {
const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date();
@@ -3,7 +3,8 @@ export enum TokenType {
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
TOKEN_EMAIL_MFA = "emailMfa",
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset"
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset",
TOKEN_USER_UNLOCK = "userUnlock"
}
export type TCreateTokenForUserDTO = {
+21
View File
@@ -44,3 +44,24 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid
if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError();
if (decodedToken.userId !== userId) throw new UnauthorizedError();
};
export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => {
if (isLocked) {
throw new UnauthorizedError({
name: "User Locked",
message:
"User is locked due to multiple failed login attempts. An email has been sent to you in order to unlock your account."
});
}
if (temporaryLockDateEnd) {
const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime();
if (timeDiff < 0)
throw new UnauthorizedError({
name: "User Locked",
message: `User is locked due to multiple failed login attempts. Try logging in again after ${Math.round(
(-1 * timeDiff) / 1000
)} seconds.`
});
}
};
@@ -13,7 +13,8 @@ import { TokenType } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { validateProviderAuthToken } from "./auth-fns";
import { processFailedMfaAttempt } from "../user/user-fns";
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
import {
TLoginClientProofDTO,
TLoginGenServerPublicKeyDTO,
@@ -212,6 +213,9 @@ export const authLoginServiceFactory = ({
});
// send multi factor auth token if they it enabled
if (userEnc.isMfaEnabled && userEnc.email) {
const user = await userDAL.findById(userEnc.userId);
enforceUserLockStatus(user.isLocked, user.temporaryLockDateEnd);
const mfaToken = jwt.sign(
{
authMethod,
@@ -300,6 +304,7 @@ export const authLoginServiceFactory = ({
const resendMfaToken = async (userId: string) => {
const user = await userDAL.findById(userId);
if (!user || !user.email) return;
enforceUserLockStatus(user.isLocked, user.temporaryLockDateEnd);
await sendUserMfaCode({
userId: user.id,
email: user.email
@@ -311,17 +316,51 @@ export const authLoginServiceFactory = ({
* Third step of login in which user completes with mfa
* */
const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId,
code: mfaToken
});
const appCfg = getConfig();
const user = await userDAL.findById(userId);
enforceUserLockStatus(user.isLocked, user.temporaryLockDateEnd);
try {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId,
code: mfaToken
});
} catch (err) {
const updatedUser = await processFailedMfaAttempt(userId, userDAL);
if (updatedUser.isLocked) {
if (updatedUser.email) {
const unlockToken = await tokenService.createTokenForUser({
type: TokenType.TOKEN_USER_UNLOCK,
userId: updatedUser.id
});
await smtpService.sendMail({
template: SmtpTemplates.UnlockAccount,
subjectLine: "Unlock your Infisical account",
recipients: [updatedUser.email],
substitutions: {
token: unlockToken,
callback_url: `${appCfg.SITE_URL}/api/v1/user/${updatedUser.id}/unlock-verify`
}
});
}
}
throw err;
}
const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload;
const userEnc = await userDAL.findUserEncKeyByUserId(userId);
if (!userEnc) throw new Error("Failed to authenticate user");
// reset lock states
await userDAL.updateById(userId, {
consecutiveFailedMfaAttempts: 0,
temporaryLockDateEnd: null
});
const token = await generateUserTokens({
user: {
...userEnc,
@@ -21,6 +21,7 @@ export enum SmtpTemplates {
EmailVerification = "emailVerification.handlebars",
SecretReminder = "secretReminder.handlebars",
EmailMfa = "emailMfa.handlebars",
UnlockAccount = "unlockAccount.handlebars",
AccessApprovalRequest = "accessApprovalRequest.handlebars",
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
NewDeviceJoin = "newDevice.handlebars",
@@ -0,0 +1,16 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Your Infisical account has been locked</title>
</head>
<body>
<h2>Unlock your Infisical account</h2>
<p>Your account has been temporarily locked due to multiple failed login attempts. </h2>
<a href="{{callback_url}}?token={{token}}">Unlock your account now</a>
<p>If these attempts were not made by you, reset your password immediately.</p>
</body>
</html>
+53
View File
@@ -1,5 +1,7 @@
import slugify from "@sindresorhus/slugify";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -19,3 +21,54 @@ export const normalizeUsername = async (username: string, userDAL: Pick<TUserDAL
}
}
};
export const processFailedMfaAttempt = async (userId: string, userDAL: Pick<TUserDALFactory, "transaction">) => {
try {
const updatedUser = await userDAL.transaction(async (tx) => {
const PROGRESSIVE_DELAY_INTERVAL = 3;
const [user] = await tx(TableName.Users)
.where("id", userId)
.increment("consecutiveFailedMfaAttempts", 1)
.returning("*");
if (!user) {
throw new Error("User not found");
}
const progressiveDelaysInMins = [5, 30, 60];
// lock user when failed attempt exceeds threshold
if (user.consecutiveFailedMfaAttempts > PROGRESSIVE_DELAY_INTERVAL * progressiveDelaysInMins.length) {
return (
await tx(TableName.Users)
.where("id", userId)
.update({
isLocked: true,
temporaryLockDateEnd: null
})
.returning("*")
)[0];
}
// delay user only when failed MFA attempts is a multiple of configured delay interval
if (user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) {
const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1;
return (
await tx(TableName.Users)
.where("id", userId)
.update({
temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000)
})
.returning("*")
)[0];
}
return user;
});
return updatedUser;
} catch (error) {
throw new DatabaseError({ error, name: "Process failed MFA Attempt" });
}
};
+15 -1
View File
@@ -207,6 +207,19 @@ export const userServiceFactory = ({
return userAction;
};
const unlockUser = async (userId: string, token: string) => {
await tokenService.validateTokenForUser({
userId,
code: token,
type: TokenType.TOKEN_USER_UNLOCK
});
await userDAL.update(
{ id: userId },
{ consecutiveFailedMfaAttempts: 0, isLocked: false, temporaryLockDateEnd: null }
);
};
return {
sendEmailVerificationCode,
verifyEmailVerificationCode,
@@ -216,6 +229,7 @@ export const userServiceFactory = ({
deleteMe,
getMe,
createUserAction,
getUserAction
getUserAction,
unlockUser
};
};