diff --git a/package.json b/package.json index a93688c..cb20c88 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "next": "^13.5.4", "next-auth": "^4.23.0", "pdfjs": "^2.5.2", + "pg": "^8.11.3", "qrcode": "^1.5.3", + "rate-limiter-flexible": "^4.0.1", "react": "18.2.0", "react-datepicker": "^4.21.0", "react-dom": "18.2.0", @@ -58,6 +60,7 @@ "@types/eslint": "^8.44.2", "@types/lodash": "^4.14.201", "@types/node": "^18.16.0", + "@types/pg": "^8.11.0", "@types/qrcode": "^1.5.5", "@types/react": "^18.2.20", "@types/react-datepicker": "^4.19.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b157953..a0ce1e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,15 @@ dependencies: pdfjs: specifier: ^2.5.2 version: 2.5.2 + pg: + specifier: ^8.11.3 + version: 8.11.3 qrcode: specifier: ^1.5.3 version: 1.5.3 + rate-limiter-flexible: + specifier: ^4.0.1 + version: 4.0.1 react: specifier: 18.2.0 version: 18.2.0 @@ -127,6 +133,9 @@ devDependencies: '@types/node': specifier: ^18.16.0 version: 18.18.7 + '@types/pg': + specifier: ^8.11.0 + version: 8.11.0 '@types/qrcode': specifier: ^1.5.5 version: 1.5.5 @@ -2578,6 +2587,14 @@ packages: resolution: {integrity: sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==} dev: false + /@types/pg@8.11.0: + resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} + dependencies: + '@types/node': 18.18.7 + pg-protocol: 1.6.0 + pg-types: 4.0.2 + dev: true + /@types/prop-types@15.7.9: resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==} @@ -3023,6 +3040,11 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true + /buffer-writer@2.0.0: + resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} + engines: {node: '>=4'} + dev: false + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -4711,6 +4733,10 @@ packages: es-abstract: 1.22.3 dev: true + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: true + /oidc-token-hash@5.0.3: resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} engines: {node: ^10.13.0 || >=12.0.0} @@ -4798,6 +4824,10 @@ packages: engines: {node: '>=6'} dev: false + /packet-reader@1.0.0: + resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + dev: false + /pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: false @@ -4863,6 +4893,86 @@ packages: uuid: 8.3.2 dev: false + /pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: true + + /pg-pool@3.6.1(pg@8.11.3): + resolution: {integrity: sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.11.3 + dev: false + + /pg-protocol@1.6.0: + resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==} + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: true + + /pg@8.11.3: + resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.6.2 + pg-pool: 3.6.1(pg@8.11.3) + pg-protocol: 1.6.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -4892,6 +5002,54 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: false + + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: true + + /postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: true + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: true + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: false + + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: true + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: true + /preact-render-to-string@5.2.6(preact@10.18.1): resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} peerDependencies: @@ -4958,6 +5116,10 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + dev: false + /react-clientside-effect@1.2.6(react@18.2.0): resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} peerDependencies: @@ -5365,6 +5527,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} dev: false @@ -6043,6 +6210,11 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: false + /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} dev: false diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14c297f..39265a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,3 +208,9 @@ model LabelTemplate { team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId String? } + +model RateLimit { + key String @id + points Int @default(0) + expire BigInt +} diff --git a/src/env.mjs b/src/env.mjs index e98985c..165c244 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -25,6 +25,7 @@ export const env = createEnv({ APP_BASE_URL: z.string().url(), MEILI_URL: z.string().url(), MEILI_MASTER_KEY: z.string(), + DISABLE_RATE_LIMIT: z.boolean().optional().default(false), PASSWORD_AUTH_ENABLED: z .string() @@ -67,6 +68,7 @@ export const env = createEnv({ APP_BASE_URL: process.env.APP_BASE_URL, MEILI_URL: process.env.MEILI_URL, MEILI_MASTER_KEY: process.env.MEILI_MASTER_KEY, + DISABLE_RATE_LIMIT: process.env.DISABLE_RATE_LIMIT, COGNITO_CLIENT_ID: process.env.COGNITO_CLIENT_ID, COGNITO_CLIENT_SECRET: process.env.COGNITO_CLIENT_SECRET, diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 2b6563d..0157adb 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -12,6 +12,9 @@ export const userRouter = createTRPCRouter({ if (ctx.session?.user) { throw new Error("User already logged in"); } - await ctx.applicationContext.userService.register(input); + await ctx.applicationContext.userService.register( + ctx.remoteAddress, + input + ); }), }); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index a88b022..2c629e4 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -16,6 +16,8 @@ import { ApplicationContext } from "~/server/lib/applicationContext"; import { getServerAuthSession } from "~/server/auth/auth"; import { db } from "~/server/db"; +import { RateLimitType } from "../lib/user/rateLimitService"; +import { env } from "~/env.mjs"; /** * 1. CONTEXT @@ -27,6 +29,7 @@ import { db } from "~/server/db"; interface CreateContextOptions { session: Session | null; + remoteAddress: string; applicationContext: ApplicationContext; } @@ -59,8 +62,24 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { // Get the session from the server using the getServerSession wrapper function const session = await getServerAuthSession({ req, res }); + const xForwardedFor = req.headers["x-forwarded-for"]; + let remoteAddress = req.socket.remoteAddress; + if (xForwardedFor) { + remoteAddress = Array.isArray(xForwardedFor) + ? xForwardedFor[0] + : xForwardedFor.split(",")[0]; + } + + if (!remoteAddress || remoteAddress.length === 0) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not determine remote address", + }); + } + return createInnerTRPCContext({ session, + remoteAddress, applicationContext: new ApplicationContext(), }); }; @@ -101,6 +120,25 @@ const t = initTRPC.context().create({ */ export const createTRPCRouter = t.router; +export const rateLimit = (type: RateLimitType) => + t.middleware(async ({ ctx, next }) => { + try { + await ctx.applicationContext.rateLimitService.consume( + type, + ctx.remoteAddress, + ctx.session?.user?.id + ); + } catch (e) { + if (e instanceof Error) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: e.message }); + } else { + throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + } + } + + return next(); + }); + /** * Public (unauthenticated) procedure * @@ -108,7 +146,7 @@ export const createTRPCRouter = t.router; * guarantee that a user querying is authorized, but you can still access user session data if they * are logged in. */ -export const publicProcedure = t.procedure; +export const publicProcedure = t.procedure.use(rateLimit("request")); /** Reusable middleware that enforces users are logged in before running the procedure. */ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { @@ -134,4 +172,6 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const protectedProcedure = t.procedure + .use(enforceUserIsAuthed) + .use(rateLimit("request")); diff --git a/src/server/auth/customCredentialsProvider.ts b/src/server/auth/customCredentialsProvider.ts index b602a95..2698224 100644 --- a/src/server/auth/customCredentialsProvider.ts +++ b/src/server/auth/customCredentialsProvider.ts @@ -1,6 +1,8 @@ import CredentialsProvider from "next-auth/providers/credentials"; -import bcrypt from "bcrypt"; import { db } from "../db"; +import { verifyPassword } from "../lib/utils/passwordUtils"; +import { defaultApplicationContext } from "../lib/applicationContext"; +import { sanitizeEmail } from "../lib/utils/emailUtils"; export const CustomCredentialsProvider = () => CredentialsProvider({ @@ -9,29 +11,56 @@ export const CustomCredentialsProvider = () => email: { label: "E-Mail", type: "email" }, password: { label: "Password", type: "password" }, }, - async authorize(credentials) { + async authorize(credentials, req) { + const { rateLimitService } = defaultApplicationContext; + if (!credentials) { return null; } + + const email = sanitizeEmail(credentials.email); + + const ip = (req.headers?.["x-forwarded-for"] || + req.headers?.["remote-addr"]) as string | undefined; + + if (!ip) { + throw new Error("No IP address found in request"); + } + + const isBlocked = await rateLimitService.isBlockedBySome( + ["login_failed_by_ip", "login_failed_by_ip_user"], + ip, + email + ); + + if (isBlocked) { + return null; + } + try { const user = await db.user.findFirst({ where: { - email: { - equals: credentials.email, - mode: "insensitive", - }, + email, }, }); - if (user?.password && credentials) { - const validPassword = await bcrypt.compare( - credentials.password, - user.password - ); + const isLoggedIn = + user?.password && + credentials && + (await verifyPassword(user.password, credentials.password)); - if (validPassword) { - return user; + if (!isLoggedIn) { + await rateLimitService.consume("login_failed_by_ip", ip); + if (user) { + await rateLimitService.consume( + "login_failed_by_ip_user", + ip, + email + ); } + } else { + await rateLimitService.delete("login_failed_by_ip_user", ip, email); + return user; } } catch (error) { console.error(error); diff --git a/src/server/lib/applicationContext.ts b/src/server/lib/applicationContext.ts index 6b85501..b06f0af 100644 --- a/src/server/lib/applicationContext.ts +++ b/src/server/lib/applicationContext.ts @@ -13,6 +13,10 @@ import { StatsService } from "./statsService"; import { LabelTemplateService } from "./label-templates/labelTemplateService"; import { TeamService } from "./user/teamService"; import { TeamDeletionService } from "./user/TeamDeletionService"; +import { parseDatabaseUrl } from "./utils/parseDatabaseUrl"; +import { env } from "~/env.mjs"; +import { RateLimitService } from "./user/rateLimitService"; +import { Pool } from "pg"; export class ApplicationContext { public readonly prismaClient = new PrismaClient(); @@ -89,6 +93,10 @@ export class ApplicationContext { this.prismaClient, this.teamService ); + public readonly rateLimitService = new RateLimitService( + this.logger.child({ name: "RateLimitService" }), + new Pool(parseDatabaseUrl(env.DATABASE_URL)) + ); } export const defaultApplicationContext = new ApplicationContext(); diff --git a/src/server/lib/user/rateLimitService.ts b/src/server/lib/user/rateLimitService.ts new file mode 100644 index 0000000..b0c8b26 --- /dev/null +++ b/src/server/lib/user/rateLimitService.ts @@ -0,0 +1,195 @@ +import { type Pool } from "pg"; +import { + type IRateLimiterPostgresOptions, + RateLimiterPostgres, + type ICallbackReady, + RateLimiterRes, +} from "rate-limiter-flexible"; +import { type Logger } from "winston"; +import { sha512 } from "../utils/sha512"; +import { env } from "~/env.mjs"; + +const rateLimitTypes = [ + "request", + "register_failed", + "register_success", + "login_failed_by_ip", + "login_failed_by_ip_user", +] as const; +export type RateLimitType = (typeof rateLimitTypes)[number]; + +type RateLimitKeyType = "ip" | "ip_user" | "user"; + +const rateLimitKeyTypes: Record = { + request: "ip", + register_failed: "ip", + register_success: "ip", + login_failed_by_ip: "ip", + login_failed_by_ip_user: "ip_user", +}; + +const rateLimitBlockDurations: Record< + RateLimitType, + Required> & + Pick +> = { + login_failed_by_ip: { + points: 100, + duration: 60 * 60 * 24, + blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day + }, + login_failed_by_ip_user: { + points: 10, + duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail + blockDuration: 60 * 60, // Block for 1 hour + }, + register_success: { + points: 5, + duration: 60 * 60 * 24, + blockDuration: 60 * 60 * 24, // Block for 1 day, if 5 accounts registered per day + }, + register_failed: { + points: 100, + duration: 60 * 60 * 24, + blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day + }, + request: { + points: 100, + duration: 60, + }, +}; + +export class RateLimitService { + private rateLimiter: Record; + + constructor(private readonly logger: Logger, readonly pool: Pool) { + const options: IRateLimiterPostgresOptions = { + storeClient: pool, + storeType: "pool", + tableName: "RateLimit", + tableCreated: true, + }; + const onReady: (type: RateLimitType) => ICallbackReady = + (type) => (error: Error | undefined) => { + if (error) { + logger.error(`RateLimiter ${type} failed to initialize`, error); + } + }; + + this.rateLimiter = Object.fromEntries( + rateLimitTypes.map((type) => [ + type, + new RateLimiterPostgres( + { + ...options, + ...rateLimitBlockDurations[type], + keyPrefix: type, + }, + onReady(type) + ), + ]) + ) as Record; + } + + private getKey = ( + type: RateLimitType, + remoteAddress: string, + userId?: string + ): string => { + const keyType = rateLimitKeyTypes[type]; + switch (keyType) { + case "ip": + return sha512(remoteAddress); + case "ip_user": + return `${userId}/${sha512(remoteAddress)}`; + case "user": + if (!userId) { + this.logger.error("User id is required for user rate limit", { + type, + remoteAddress, + userId, + }); + throw new Error("Rate limit exceeded"); + } + return userId; + default: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = keyType; + } + throw new Error("Rate limit exceeded"); + }; + + consume = async ( + type: RateLimitType, + remoteAddress: string, + userId?: string + ) => { + if (env.DISABLE_RATE_LIMIT) { + return; + } + try { + await this.rateLimiter[type].consume( + this.getKey(type, remoteAddress, userId), + 1 + ); + } catch (e) { + if (e instanceof RateLimiterRes) { + this.logger.info(`Rate limit exceeded`, { + error: e, + remoteAddress, + type, + }); + } + throw new Error(`Rate limit exceeded`); + } + }; + + delete = async ( + type: RateLimitType, + remoteAddress: string, + userId?: string + ) => { + if (env.DISABLE_RATE_LIMIT) { + return; + } + return await this.rateLimiter[type].delete( + this.getKey(type, remoteAddress, userId) + ); + }; + + get = async (type: RateLimitType, remoteAddress: string, userId?: string) => { + if (env.DISABLE_RATE_LIMIT) { + return; + } + return await this.rateLimiter[type].get( + this.getKey(type, remoteAddress, userId) + ); + }; + + isBlocked = async ( + type: RateLimitType, + remoteAddress: string, + userId?: string + ): Promise => { + if (env.DISABLE_RATE_LIMIT) { + return false; + } + const res = await this.get(type, remoteAddress, userId); + const limit = rateLimitBlockDurations[type].points; + return !!res && res.consumedPoints >= limit; + }; + + isBlockedBySome = async ( + types: RateLimitType[], + remoteAddress: string, + userId?: string + ): Promise => { + if (env.DISABLE_RATE_LIMIT) { + return false; + } + const results = await Promise.all( + types.map((type) => this.isBlocked(type, remoteAddress, userId)) + ); + return results.some((result) => result); + }; +} diff --git a/src/server/lib/user/userService.ts b/src/server/lib/user/userService.ts index 779b806..6af7e6e 100644 --- a/src/server/lib/user/userService.ts +++ b/src/server/lib/user/userService.ts @@ -2,14 +2,16 @@ import { type PrismaClient } from "@prisma/client"; import { type Logger } from "winston"; import { type TeamService } from "./teamService"; import { type UserRegisterRequest } from "./userRegisterRequest"; -import { validateEmail } from "../utils/emailUtils"; +import { sanitizeEmail, validateEmail } from "../utils/emailUtils"; import { hashPassword } from "../utils/passwordUtils"; +import { type RateLimitService } from "./rateLimitService"; export class UserService { constructor( private readonly logger: Logger, private readonly prisma: PrismaClient, - private readonly teamService: TeamService + private readonly teamService: TeamService, + private readonly rateLimitService: RateLimitService ) {} public initialize = async (userId: string) => { @@ -29,25 +31,47 @@ export class UserService { } }; - public register = async (request: UserRegisterRequest) => { + public register = async ( + remoteAddress: string, + request: UserRegisterRequest + ) => { const { email, password } = request; - const sanitizedEmail = email.toLowerCase().trim(); + const sanitizedEmail = sanitizeEmail(email); const exists = await this.prisma.user.count({ where: { email: sanitizedEmail }, }); + + const isBlocked = await this.rateLimitService.isBlockedBySome( + ["register_failed", "register_success"], + remoteAddress + ); + + if (isBlocked) { + this.logger.error("Failed to register account: Rate limit exceeded", { + remoteAddress, + email, + }); + throw new Error("Something went wrong"); + } + if (exists > 0) { + await this.rateLimitService.consume("register_failed", remoteAddress); throw new Error("User already exists"); } if (!validateEmail(sanitizedEmail)) { + await this.rateLimitService.consume("register_failed", remoteAddress); throw new Error("Invalid email address"); } if (password.length < 8) { + await this.rateLimitService.consume("register_failed", remoteAddress); throw new Error("Password must be at least 8 characters long"); } if (password.length > 255) { + await this.rateLimitService.consume("register_failed", remoteAddress); throw new Error("Password must be at most 255 characters long"); } this.logger.info("Registering new user", { email: request.email }); + await this.rateLimitService.consume("register_success", remoteAddress); const user = await this.prisma.user.create({ data: { email: sanitizedEmail, diff --git a/src/server/lib/utils/emailUtils.ts b/src/server/lib/utils/emailUtils.ts index 41cd088..68f99d5 100644 --- a/src/server/lib/utils/emailUtils.ts +++ b/src/server/lib/utils/emailUtils.ts @@ -2,3 +2,7 @@ export const validateEmail = (email: string) => { const regex = /^\S+@\S+\.\S+$/; return regex.test(email); }; + +export const sanitizeEmail = (email: string) => { + return email.toLowerCase().trim(); +}; diff --git a/src/server/lib/utils/parseDatabaseUrl.ts b/src/server/lib/utils/parseDatabaseUrl.ts new file mode 100644 index 0000000..e8c0bc4 --- /dev/null +++ b/src/server/lib/utils/parseDatabaseUrl.ts @@ -0,0 +1,12 @@ +import { type ClientConfig } from "pg"; + +export const parseDatabaseUrl = (databaseUrl: string): ClientConfig => { + const url = new URL(databaseUrl); + return { + host: url.hostname, + port: parseInt(url.port, 10), + database: url.pathname.slice(1), + user: url.username, + password: url.password, + }; +}; diff --git a/src/server/lib/utils/sha512.ts b/src/server/lib/utils/sha512.ts new file mode 100644 index 0000000..cdddfb1 --- /dev/null +++ b/src/server/lib/utils/sha512.ts @@ -0,0 +1,5 @@ +import { createHash } from "crypto"; + +export const sha512 = (input: string): string => { + return createHash("sha512").update(input).digest("hex"); +};