diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 7b4b9a99b..d3d3d3efd 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -11,7 +11,7 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => { return { errorResponseBuilder: (_, context) => { throw new RateLimitError({ - message: `Rate limit exceeded. Please try again in ${context.after}` + message: `Rate limit exceeded. Please try again in ${Math.ceil(context.ttl / 1000)} seconds` }); }, timeWindow: 60 * 1000, @@ -113,3 +113,12 @@ export const requestAccessLimit: RateLimitOptions = { max: 10, keyGenerator: (req) => req.realIp }; + +export const smtpRateLimit = ({ + keyGenerator = (req) => req.realIp +}: Pick = {}): RateLimitOptions => ({ + timeWindow: 40 * 1000, + hook: "preValidation", + max: 2, + keyGenerator +}); diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 77ae0e627..117532703 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; -import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; +import { inviteUserRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -11,7 +11,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit() }, method: "POST", schema: { @@ -81,7 +81,9 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup-resend", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { membershipId: string }).membershipId + }) }, method: "POST", schema: { diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts index cc0543d4c..d4b1ee188 100644 --- a/backend/src/server/routes/v1/org-admin-router.ts +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -47,7 +47,9 @@ export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/projects/:projectId/grant-admin-access", config: { - rateLimit: writeLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { params: z.object({ diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 724468e02..32921087b 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { BackupPrivateKeySchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validateSignUpAuthorization } from "@app/services/auth/auth-fns"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { UserEncryption } from "@app/services/user/user-types"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { @@ -80,7 +80,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-reset", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email: string }).email + }) }, schema: { body: z.object({ @@ -224,7 +226,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-setup", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { response: { @@ -233,6 +237,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { await server.services.password.sendPasswordSetupEmail(req.permission); @@ -267,6 +272,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req, res) => { await server.services.password.setupPassword(req.body, req.permission); diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 027f527fc..730d34bb9 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, smtpRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; @@ -12,7 +12,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/me/emails/code", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { username: string }).username + }) }, schema: { body: z.object({ diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 552253cde..275836b1e 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError } from "@app/lib/errors"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -13,7 +13,9 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { url: "/email/signup", method: "POST", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email: string }).email + }) }, schema: { body: z.object({ diff --git a/frontend/src/components/auth/CodeInputStep.tsx b/frontend/src/components/auth/CodeInputStep.tsx index 09958fafd..f992c8da6 100644 --- a/frontend/src/components/auth/CodeInputStep.tsx +++ b/frontend/src/components/auth/CodeInputStep.tsx @@ -78,11 +78,14 @@ export default function CodeInputStep({ const resendVerificationEmail = async () => { setIsResendingVerificationEmail(true); setIsLoading(true); - await mutateAsync({ email }); - setTimeout(() => { - setIsLoading(false); - setIsResendingVerificationEmail(false); - }, 2000); + try { + await mutateAsync({ email }); + } finally { + setTimeout(() => { + setIsLoading(false); + setIsResendingVerificationEmail(false); + }, 1000); + } }; return ( diff --git a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx index 47012e2cb..9777f564a 100644 --- a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx +++ b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -22,8 +22,12 @@ export const VerifyEmailPage = () => { */ const sendVerificationEmail = async () => { if (email) { - await mutateAsync({ email }); - setStep(2); + try { + await mutateAsync({ email }); + setStep(2); + } catch { + setLoading(false); + } } }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index f9c454985..ec104d391 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -159,6 +159,7 @@ export const AddOrgMemberModal = ({ text: "Failed to invite user to org", type: "error" }); + return; } if (serverDetails?.emailConfigured) { diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index d50baf135..811bf57e7 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -163,6 +163,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { text: "Failed to add user to project", type: "error" }); + return; } handlePopUpToggle("addMember", false); reset();