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");
}
});
}
10 changes: 8 additions & 2 deletions backend/src/db/schemas/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ 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(),
isLocked: z.boolean(),
temporaryLockDateEnd: z.date().nullable().optional()
});

export type TUsers = z.infer<typeof UsersSchema>;
export type TUsersInsert = Omit<z.input<typeof UsersSchema>, TImmutableDBKeys>;
export type TUsersInsert = Omit<
z.input<typeof UsersSchema>,
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
TImmutableDBKeys | "isLocked" | "consecutiveFailedMfaAttempts"
>;
export type TUsersUpdate = Partial<Omit<z.input<typeof UsersSchema>, TImmutableDBKeys>>;
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-verify",
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
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
21 changes: 21 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,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) => {
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."
});
}

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 again after ${Math.round(
(-1 * timeDiff) / 1000
)} seconds.`
});
}
};
51 changes: 45 additions & 6 deletions backend/src/services/auth/auth-login-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
akhilmhdh marked this conversation as resolved.
Show resolved Hide resolved
enforceUserLockStatus(user.isLocked, user.temporaryLockDateEnd);

const mfaToken = jwt.sign(
{
authMethod,
Expand Down Expand Up @@ -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
Expand All @@ -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,
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>
53 changes: 53 additions & 0 deletions backend/src/services/user/user-fns.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,7 +11,7 @@
let user = await userDAL.findOne({ username: attempt });
if (!user) return attempt;

while (true) {

Check warning on line 14 in backend/src/services/user/user-fns.ts

View workflow job for this annotation

GitHub Actions / Check TS and Lint

Unexpected constant condition
attempt = slugify(`${username}-${alphaNumericNanoId(4)}`);
// eslint-disable-next-line no-await-in-loop
user = await userDAL.findOne({ username: attempt });
Expand All @@ -19,3 +21,54 @@
}
}
};

export const processFailedMfaAttempt = async (userId: string, userDAL: Pick<TUserDALFactory, "transaction">) => {
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
try {
const updatedUser = await userDAL.transaction(async (tx) => {
const PROGRESSIVE_DELAY_INTERVAL = 3;
const [user] = await tx(TableName.Users)
akhilmhdh marked this conversation as resolved.
Show resolved Hide resolved
.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 + 1)) {
return (
await tx(TableName.Users)
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
.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) {
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
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" });
}
};
Loading
Loading