Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: secure mfa endpoints with improved rate limiting and account locking #1861

Merged
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts");
const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked");
const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd");

await knex.schema.alterTable(TableName.Users, (t) => {
if (!hasConsecutiveFailedMfaAttempts) {
t.integer("consecutiveFailedMfaAttempts").defaultTo(0);
}

if (!hasIsLocked) {
t.boolean("isLocked").defaultTo(false);
}

if (!hasTemporaryLockDateEnd) {
t.dateTime("temporaryLockDateEnd").nullable();
}
});
}

export async function down(knex: Knex): Promise<void> {
const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts");
const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked");
const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd");

await knex.schema.alterTable(TableName.Users, (t) => {
if (hasConsecutiveFailedMfaAttempts) {
t.dropColumn("consecutiveFailedMfaAttempts");
}

if (hasIsLocked) {
t.dropColumn("isLocked");
}

if (hasTemporaryLockDateEnd) {
t.dropColumn("temporaryLockDateEnd");
}
});
}
5 changes: 4 additions & 1 deletion backend/src/db/schemas/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export const UsersSchema = z.object({
updatedAt: z.date(),
isGhost: z.boolean().default(false),
username: z.string(),
isEmailVerified: z.boolean().default(false).nullable().optional()
isEmailVerified: z.boolean().default(false).nullable().optional(),
consecutiveFailedMfaAttempts: z.number().optional(),
isLocked: z.boolean().optional(),
temporaryLockDateEnd: z.date().nullable().optional()
});

export type TUsers = z.infer<typeof UsersSchema>;
Expand Down
8 changes: 8 additions & 0 deletions backend/src/server/config/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export const inviteUserRateLimit: RateLimitOptions = {
keyGenerator: (req) => req.realIp
};

export const mfaRateLimit: RateLimitOptions = {
timeWindow: 60 * 1000,
max: 20,
keyGenerator: (req) => {
return req.headers.authorization?.split(" ")[1] || req.realIp;
}
};

export const creationLimit: RateLimitOptions = {
// identity, project, org
timeWindow: 60 * 1000,
Expand Down
31 changes: 30 additions & 1 deletion backend/src/server/routes/v1/user-router.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { z } from "zod";

import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import { readLimit } from "@app/server/config/rateLimiter";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { authRateLimit, readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";

export const registerUserRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();

server.route({
method: "GET",
url: "/",
Expand All @@ -25,4 +29,29 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
return { user };
}
});

server.route({
method: "GET",
url: "/:userId/unlock",
config: {
rateLimit: authRateLimit
},
schema: {
querystring: z.object({
token: z.string().trim()
}),
params: z.object({
userId: z.string()
})
},
handler: async (req, res) => {
try {
await server.services.user.unlockUser(req.params.userId, req.query.token);
} catch (err) {
logger.error(`User unlock failed for ${req.params.userId}`);
logger.error(err);
}
return res.redirect(`${appCfg.SITE_URL}/login`);
}
});
};
6 changes: 3 additions & 3 deletions backend/src/server/routes/v2/mfa-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken";
import { z } from "zod";

import { getConfig } from "@app/lib/config/env";
import { writeLimit } from "@app/server/config/rateLimiter";
import { mfaRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";

export const registerMfaRouter = async (server: FastifyZodProvider) => {
Expand Down Expand Up @@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
method: "POST",
url: "/mfa/send",
config: {
rateLimit: writeLimit
rateLimit: mfaRateLimit
},
schema: {
response: {
Expand All @@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
url: "/mfa/verify",
method: "POST",
config: {
rateLimit: writeLimit
rateLimit: mfaRateLimit
},
schema: {
body: z.object({
Expand Down
8 changes: 7 additions & 1 deletion backend/src/services/auth-token/auth-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/auth-token/auth-token-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
24 changes: 24 additions & 0 deletions backend/src/services/auth/auth-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,27 @@ 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) => {
akhilmhdh marked this conversation as resolved.
Show resolved Hide resolved
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. You can also reset your password to unlock."
});
}

if (temporaryLockDateEnd) {
const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime();
if (timeDiff < 0) {
const secondsDiff = (-1 * timeDiff) / 1000;
const timeDisplay =
secondsDiff > 60 ? `${Math.ceil(secondsDiff / 60)} minutes` : `${Math.ceil(secondsDiff)} seconds`;

throw new UnauthorizedError({
name: "User Locked",
message: `User is temporary locked due to multiple failed login attempts. Try again after ${timeDisplay}. You can also reset your password now to proceed.`
});
}
}
};
100 changes: 93 additions & 7 deletions backend/src/services/auth/auth-login-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas";
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
import { getConfig } from "@app/lib/config/env";
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";

import { TTokenDALFactory } from "../auth-token/auth-token-dal";
Expand All @@ -13,7 +13,7 @@ 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 { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
import {
TLoginClientProofDTO,
TLoginGenServerPublicKeyDTO,
Expand Down Expand Up @@ -212,6 +212,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);
akhilmhdh marked this conversation as resolved.
Show resolved Hide resolved
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);

const mfaToken = jwt.sign(
{
authMethod,
Expand Down Expand Up @@ -300,28 +303,111 @@ export const authLoginServiceFactory = ({
const resendMfaToken = async (userId: string) => {
const user = await userDAL.findById(userId);
if (!user || !user.email) return;
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
await sendUserMfaCode({
userId: user.id,
email: user.email
});
};

const processFailedMfaAttempt = async (userId: string) => {
try {
const updatedUser = await userDAL.transaction(async (tx) => {
const PROGRESSIVE_DELAY_INTERVAL = 3;
const user = await userDAL.updateById(userId, { $incr: { consecutiveFailedMfaAttempts: 1 } }, tx);

if (!user) {
throw new Error("User not found");
}

const progressiveDelaysInMins = [5, 30, 60];

// lock user when failed attempt exceeds threshold
if (
user.consecutiveFailedMfaAttempts &&
user.consecutiveFailedMfaAttempts >= PROGRESSIVE_DELAY_INTERVAL * (progressiveDelaysInMins.length + 1)
) {
return userDAL.updateById(
userId,
{
isLocked: true,
temporaryLockDateEnd: null
},
tx
);
}

// delay user only when failed MFA attempts is a multiple of configured delay interval
if (user.consecutiveFailedMfaAttempts && user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) {
const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1;
return userDAL.updateById(
userId,
{
temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000)
},
tx
);
}

return user;
});

return updatedUser;
} catch (error) {
throw new DatabaseError({ error, name: "Process failed MFA Attempt" });
}
};

/*
* 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) => {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId,
code: mfaToken
});
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
});
} catch (err) {
const updatedUser = await processFailedMfaAttempt(userId);
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`
}
});
}
}

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,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/services/auth/auth-password-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ export const authPaswordServiceFactory = ({
salt,
verifier
});

await userDAL.updateById(userId, {
isLocked: false,
temporaryLockDateEnd: null,
consecutiveFailedMfaAttempts: 0
});
};

/*
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/smtp/smtp-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions backend/src/services/smtp/templates/unlockAccount.handlebars
Original file line number Diff line number Diff line change
@@ -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>
Loading
Loading