diff --git a/backend/src/db/migrations/20240522072006_add-user-account-mfa-locking.ts b/backend/src/db/migrations/20240522072006_add-user-account-mfa-locking.ts new file mode 100644 index 000000000..2b2ecd783 --- /dev/null +++ b/backend/src/db/migrations/20240522072006_add-user-account-mfa-locking.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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"); + } + }); +} diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index d5a4d5b49..935351d33 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -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; -export type TUsersInsert = Omit, TImmutableDBKeys>; +export type TUsersInsert = Omit< + z.input, + TImmutableDBKeys | "isLocked" | "consecutiveFailedMfaAttempts" +>; export type TUsersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index bdede8a3a..aa3962c5a 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -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: "/", @@ -25,4 +29,28 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return { user }; } }); + + server.route({ + method: "GET", + url: "/:userId/unlock-verify", + 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}`); + } + return res.redirect(`${appCfg.SITE_URL}/login`); + } + }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 5d68a4e94..b1f8aa2f6 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; - userDAL: Pick; + userDAL: Pick; }; + export type TAuthTokenServiceFactory = ReturnType; 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(); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 630e36310..8917bd672 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -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 = { diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 80fb0b325..4911cc258 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -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.` + }); + } +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 4d2a302c6..96c32c0df 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -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, diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 81680537d..7d6b98b31 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -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", diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars new file mode 100644 index 000000000..cb1859e51 --- /dev/null +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -0,0 +1,16 @@ + + + + + + Your Infisical account has been locked + + + +

Unlock your Infisical account

+

Your account has been temporarily locked due to multiple failed login attempts. + Unlock your account now +

If these attempts were not made by you, reset your password immediately.

+ + + \ No newline at end of file diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 639320e24..2750bb87e 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -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) => { + 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" }); + } +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 089f3b8c6..a82259db6 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -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 }; };