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..c10af4ba4 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -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; diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 6c92de62c..dfecfb495 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -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, diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index bdede8a3a..3d9f531b9 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,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`); + } + }); }; diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 973804c7c..1c685866d 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -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) => { @@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/mfa/send", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { response: { @@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { url: "/mfa/verify", method: "POST", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { body: z.object({ 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..2a322fdea 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -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) => { + 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.` + }); + } + } +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 4d2a302c6..cbf43b245 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -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"; @@ -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, @@ -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); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + const mfaToken = jwt.sign( { authMethod, @@ -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, diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 4025e4903..a400c297b 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -174,6 +174,12 @@ export const authPaswordServiceFactory = ({ salt, verifier }); + + await userDAL.updateById(userId, { + isLocked: false, + temporaryLockDateEnd: null, + consecutiveFailedMfaAttempts: 0 + }); }; /* 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-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 }; }; diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index f3b40300e..a4e5e89f5 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -105,8 +105,18 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } } - } catch (err) { + } catch (err: any) { console.error(err); + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + setIsLoading(false); + return; + } + setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", diff --git a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx index eaaac88a9..bf90b9cab 100644 --- a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx +++ b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx @@ -46,20 +46,7 @@ type Props = { callbackPort?: string | null; }; -interface VerifyMfaTokenError { - response: { - data: { - context: { - code: string; - triesLeft: number; - }; - }; - status: number; - }; -} - export const MFAStep = ({ email, password, providerAuthToken }: Props) => { - const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); @@ -178,20 +165,31 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { }); } } - } catch (err) { - const error = err as VerifyMfaTokenError; + } catch (err: any) { + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + setIsLoading(false); + return; + } + createNotification({ text: "Failed to log in", type: "error" }); - if (error?.response?.status === 500) { - window.location.reload(); - } else if (error?.response?.data?.context?.triesLeft) { - setTriesLeft(error?.response?.data?.context?.triesLeft); - if (error.response.data.context.triesLeft === 0) { - window.location.reload(); - } + if (triesLeft) { + setTriesLeft((left) => { + if (triesLeft === 1) { + router.push("/"); + } + return (left as number) - 1; + }); + } else { + setTriesLeft(2); } setIsLoading(false);