mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
feat: better ratelimiting (#2520)
Replace hono-rate-limiter with a Prisma/PostgreSQL bucketed counter approach that works correctly across multiple instances without sticky sessions. - Add RateLimit model with composite PK (key, action, bucket) and atomic upsert - Create rate limit factory with window parsing, bucket computation, and fail-open - Define auth-tier and API-tier rate limit instances - Add Hono middleware, rateLimitResponse helper, and tRPC assertRateLimit helper - Wire rate limit headers through AppError constructor (was declared but never assigned) - Apply rate limits to auth routes (email-password, passkey), tRPC routes (2FA email, link org account), API routes, and file upload endpoints - Add cleanup cron job for expired rate limit rows (batched delete every 15 min) - Remove hono-rate-limiter dependency
This commit is contained in:
@@ -2,6 +2,7 @@ import { sValidator } from '@hono/standard-validator';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -14,6 +15,15 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
|
||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
forgotPasswordRateLimit,
|
||||
loginRateLimit,
|
||||
resendVerifyEmailRateLimit,
|
||||
resetPasswordRateLimit,
|
||||
signupRateLimit,
|
||||
verifyEmailRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
@@ -51,6 +61,19 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const loginLimited = rateLimitResponse(c, loginLimitResult);
|
||||
|
||||
if (loginLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: loginLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const csrfCookieToken = await getCsrfCookie(c);
|
||||
|
||||
// Todo: (RR7) Add logging here.
|
||||
@@ -152,6 +175,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
* Signup endpoint.
|
||||
*/
|
||||
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
throw new AppError('SIGNUP_DISABLED', {
|
||||
message: 'Signups are disabled.',
|
||||
@@ -160,6 +185,18 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const { name, email, password, signature } = c.req.valid('json');
|
||||
|
||||
const signupLimitResult = await signupRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const signupLimited = rateLimitResponse(c, signupLimitResult);
|
||||
|
||||
if (signupLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: signupLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
@@ -219,7 +256,24 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
* Verify email endpoint.
|
||||
*/
|
||||
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
|
||||
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const verifyLimitResult = await verifyEmailRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: token,
|
||||
});
|
||||
|
||||
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
|
||||
|
||||
if (verifyLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: verifyLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const { state, userId } = await verifyEmail({ token });
|
||||
|
||||
// If email is verified, automatically authenticate user.
|
||||
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
|
||||
@@ -234,8 +288,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
* Resend verification email endpoint.
|
||||
*/
|
||||
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const resendLimitResult = await resendVerifyEmailRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const resendLimited = rateLimitResponse(c, resendLimitResult);
|
||||
|
||||
if (resendLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: resendLimited,
|
||||
});
|
||||
}
|
||||
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
@@ -249,8 +318,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
* Forgot password endpoint.
|
||||
*/
|
||||
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: email,
|
||||
});
|
||||
|
||||
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
|
||||
|
||||
if (forgotLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: forgotLimited,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
@@ -268,8 +352,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
* Reset password endpoint.
|
||||
*/
|
||||
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
const resetLimitResult = await resetPasswordRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: token,
|
||||
});
|
||||
|
||||
const resetLimited = rateLimitResponse(c, resetLimitResult);
|
||||
|
||||
if (resetLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: resetLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await getUserByResetToken({ token });
|
||||
|
||||
if (
|
||||
@@ -279,8 +378,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
return c.text('FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { userId } = await resetPassword({
|
||||
token,
|
||||
password,
|
||||
|
||||
@@ -3,8 +3,11 @@ import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { passkeyRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||
@@ -23,6 +26,18 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const passkeyLimitResult = await passkeyRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
});
|
||||
|
||||
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
|
||||
|
||||
if (passkeyLimited) {
|
||||
throw new HTTPException(429, {
|
||||
res: passkeyLimited,
|
||||
});
|
||||
}
|
||||
|
||||
const { csrfToken, credential } = c.req.valid('json');
|
||||
|
||||
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user