diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 1c9c1b0..bc546ae 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -5,6 +5,17 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "~/server/auth.js"; +function extractClientIp(req: NextRequest): string | null { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = req.headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + return null; +} + // Throttle lastActiveAt updates: max once per 60s per user const lastActiveCache = new Map(); const ACTIVITY_THROTTLE_MS = 60_000; @@ -14,10 +25,14 @@ function trackActivity(userId: string) { const last = lastActiveCache.get(userId) ?? 0; if (now - last < ACTIVITY_THROTTLE_MS) return; lastActiveCache.set(userId, now); - prisma.user.update({ - where: { id: userId }, - data: { lastActiveAt: new Date(now) }, - }).catch(() => {/* ignore */}); + prisma.user + .update({ + where: { id: userId }, + data: { lastActiveAt: new Date(now) }, + }) + .catch(() => { + /* ignore */ + }); } const handler = async (req: NextRequest) => { @@ -63,7 +78,8 @@ const handler = async (req: NextRequest) => { endpoint: "/api/trpc", req, router: appRouter, - createContext: () => createTRPCContext({ session, dbUser, roleDefaults }), + createContext: () => + createTRPCContext({ session, dbUser, roleDefaults, clientIp: extractClientIp(req) }), }; if (process.env["NODE_ENV"] === "development") { diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 5f84124..9fbe74c 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -31,6 +31,18 @@ const LoginSchema = z.object({ totp: z.string().max(16).optional(), }); +function extractClientIp(request: Request | undefined): string | null { + if (!request) return null; + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = request.headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + return null; +} + const config = { ...authConfig, trustHost: true, @@ -42,17 +54,25 @@ const config = { password: { label: "Password", type: "password" }, totp: { label: "TOTP", type: "text" }, }, - async authorize(credentials) { + async authorize(credentials, request) { const parsed = LoginSchema.safeParse(credentials); if (!parsed.success) return null; const { email, password, totp } = parsed.data; const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true"; - // Rate limit: 5 login attempts per 15 minutes per email + // Rate limit: 5 attempts per 15 min, keyed on BOTH email and + // source IP. Keying on email alone permits per-email lockout DoS + // and lets a single IP brute-force unlimited emails; keying on + // IP alone lets a botnet bypass the limit. Both buckets must be + // within budget for the attempt to proceed (CWE-307). + const ip = extractClientIp(request); + const rateLimitKeys = ip + ? [`email:${email.toLowerCase()}`, `ip:${ip}`] + : [`email:${email.toLowerCase()}`]; const rateLimitResult = isE2eTestMode ? { allowed: true } - : await authRateLimiter(email.toLowerCase()); + : await authRateLimiter(rateLimitKeys); if (!rateLimitResult.allowed) { // Audit failed login (rate limited) void createAuditEntry({ diff --git a/packages/api/src/__tests__/rate-limit.test.ts b/packages/api/src/__tests__/rate-limit.test.ts index e10eb93..18fb97f 100644 --- a/packages/api/src/__tests__/rate-limit.test.ts +++ b/packages/api/src/__tests__/rate-limit.test.ts @@ -103,9 +103,9 @@ describe("rate limiter", () => { })); const { createRateLimiter } = await import("../middleware/rate-limit.js"); - // Degraded fallback uses max(1, floor(maxRequests/10)), so with - // maxRequests=20 the degraded limit is 2. - const limiter = createRateLimiter(60_000, 20, { + // Degraded fallback uses max(1, floor(maxRequests/2)), so with + // maxRequests=4 the degraded limit is 2 attempts within the window. + const limiter = createRateLimiter(60_000, 4, { backend: "redis", redisUrl: "redis://test", name: "redis-fallback-test", @@ -120,4 +120,39 @@ describe("rate limiter", () => { expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); }); + + it("denies by default when called with an empty key (fail-closed)", async () => { + const { createRateLimiter } = await import("../middleware/rate-limit.js"); + const limiter = createRateLimiter(60_000, 5, { backend: "memory", name: "empty-key-test" }); + + const empty = await limiter(""); + const whitespace = await limiter(" "); + const emptyArray = await limiter([]); + const allEmpty = await limiter(["", " "]); + + expect(empty.allowed).toBe(false); + expect(whitespace.allowed).toBe(false); + expect(emptyArray.allowed).toBe(false); + expect(allEmpty.allowed).toBe(false); + }); + + it("denies if any key in a multi-key call is over its limit", async () => { + const { createRateLimiter } = await import("../middleware/rate-limit.js"); + const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "multi-key-test" }); + + // Exhaust the "email:a" bucket alone + await limiter("email:a"); + await limiter("email:a"); + const emailExhausted = await limiter("email:a"); + expect(emailExhausted.allowed).toBe(false); + + // A call keyed on both email:a AND ip:x must deny because email:a is + // exhausted, even though ip:x is fresh. + const combined = await limiter(["email:a", "ip:x"]); + expect(combined.allowed).toBe(false); + + // A fresh bucket pair still succeeds. + const freshPair = await limiter(["email:b", "ip:y"]); + expect(freshPair.allowed).toBe(true); + }); }); diff --git a/packages/api/src/__tests__/user-self-service-mfa.test.ts b/packages/api/src/__tests__/user-self-service-mfa.test.ts index bbfcb90..5c4c2b3 100644 --- a/packages/api/src/__tests__/user-self-service-mfa.test.ts +++ b/packages/api/src/__tests__/user-self-service-mfa.test.ts @@ -90,15 +90,16 @@ function makeSelfServiceCtx(dbOverrides: Record = {}) { }; } -function makePublicCtx(dbOverrides: Record = {}) { +function makePublicCtx(overrides: Record = {}) { return { db: { user: { findUnique: vi.fn(), update: vi.fn().mockResolvedValue({}), - ...((dbOverrides.user as object | undefined) ?? {}), + ...((overrides.user as object | undefined) ?? {}), }, }, + clientIp: (overrides.clientIp as string | null | undefined) ?? null, }; } @@ -277,14 +278,27 @@ describe("verifyTotp", () => { expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); }); - it("calls the rate limiter with the userId as key", async () => { + it("calls the rate limiter with both userId and client IP as keys", async () => { + totpValidateMock.mockReturnValue(0); + const ctx = makePublicCtx({ + user: { findUnique: vi.fn().mockResolvedValue(mfaUser) }, + clientIp: "198.51.100.7", + }); + await verifyTotp(ctx as Parameters[0], { + userId: "user_1", + token: "123456", + }); + expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1", "ip:198.51.100.7"]); + }); + + it("falls back to userId-only keying when no client IP is available", async () => { totpValidateMock.mockReturnValue(0); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456", }); - expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1"); + expect(totpRateLimiterMock).toHaveBeenCalledWith(["user:user_1"]); }); }); diff --git a/packages/api/src/middleware/rate-limit.ts b/packages/api/src/middleware/rate-limit.ts index 75d2438..5a6c18b 100644 --- a/packages/api/src/middleware/rate-limit.ts +++ b/packages/api/src/middleware/rate-limit.ts @@ -22,7 +22,7 @@ type CreateRateLimiterOptions = { }; export interface RateLimiter { - (key: string): Promise; + (key: string | readonly string[]): Promise; reset(): Promise; } @@ -212,27 +212,19 @@ export function createRateLimiter( // When Redis is unavailable, apply a stricter limit to compensate for // per-node isolation (each process keeps independent in-memory counters, - // so the effective cluster-wide limit is maxRequests × nodeCount). + // so the effective cluster-wide limit is maxRequests × nodeCount). A + // /2 divisor keeps legitimate users out of forced-logout while still + // meaningfully slowing distributed brute-force during Redis outages. const degradedMemoryBackend = createMemoryBackend( windowMs, - Math.max(1, Math.floor(maxRequests / 10)), + Math.max(1, Math.floor(maxRequests / 2)), ); let redisDegraded = false; - const check = (async (key: string) => { - const normalizedKey = key.trim().toLowerCase(); - if (!normalizedKey) { - return { - allowed: true, - remaining: maxRequests, - resetAt: new Date(Date.now() + windowMs), - }; - } - + async function checkOne(normalizedKey: string): Promise { if (!redisBackend) { return memoryBackend.check(normalizedKey); } - try { const result = await redisBackend.check(normalizedKey); if (redisDegraded) { @@ -244,6 +236,44 @@ export function createRateLimiter( redisDegraded = true; return degradedMemoryBackend.check(normalizedKey); } + } + + const check = (async (key: string | readonly string[]) => { + const rawKeys = Array.isArray(key) ? key : [key as string]; + const normalizedKeys = rawKeys + .map((k) => (typeof k === "string" ? k.trim().toLowerCase() : "")) + .filter((k) => k.length > 0); + + // Fail-closed: if every supplied key is empty or whitespace the caller + // has no identity to throttle; deny rather than letting unbounded + // attempts through (CWE-307). + if (normalizedKeys.length === 0) { + logger.warn({ limiter: name }, "Rate limiter called with empty key — denying by default"); + return { + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + windowMs), + }; + } + + // Check every bucket. If any bucket is exhausted, the request is + // denied; this allows callers to key on both user identifier AND + // request IP without either becoming a bypass. + let denied: RateLimitResult | null = null; + let earliestReset = new Date(Date.now() + windowMs); + let minRemaining = Number.POSITIVE_INFINITY; + for (const normalizedKey of normalizedKeys) { + const result = await checkOne(normalizedKey); + if (!result.allowed && !denied) denied = result; + if (result.resetAt < earliestReset) earliestReset = result.resetAt; + if (result.remaining < minRemaining) minRemaining = result.remaining; + } + if (denied) return denied; + return { + allowed: true, + remaining: minRemaining === Number.POSITIVE_INFINITY ? maxRequests : minRemaining, + resetAt: earliestReset, + }; }) as RateLimiter; check.reset = async () => { diff --git a/packages/api/src/router/assistant-tools/helpers.ts b/packages/api/src/router/assistant-tools/helpers.ts index ea35028..89e3210 100644 --- a/packages/api/src/router/assistant-tools/helpers.ts +++ b/packages/api/src/router/assistant-tools/helpers.ts @@ -1677,6 +1677,7 @@ export function createScopedCallerContext(ctx: ToolContext): TRPCContext { db: ctx.db, dbUser: ctx.dbUser, roleDefaults: ctx.roleDefaults ?? null, + clientIp: null, }; } diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index caf7376..7a37ed3 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -27,7 +27,11 @@ export const authRouter = createTRPCRouter({ requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { - const rl = await authRateLimiter(input.email); + const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; + const keys = ipKey + ? [`email:${input.email.toLowerCase()}`, ipKey] + : [`email:${input.email.toLowerCase()}`]; + const rl = await authRateLimiter(keys); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", @@ -78,12 +82,19 @@ export const authRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const rl = await authRateLimiter(input.token); - if (!rl.allowed) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "Too many password reset attempts. Please wait before trying again.", - }); + // Rate-limit keyed on IP (token is always new so token-keying is a no-op). + // We cannot key on the resolved email before the token lookup; fall back + // to IP-only here and apply an email-keyed limit AFTER the successful + // lookup to bound per-email brute-force. + const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; + if (ipKey) { + const rl = await authRateLimiter(ipKey); + if (!rl.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many password reset attempts. Please wait before trying again.", + }); + } } const record = await ctx.db.passwordResetToken.findUnique({ @@ -103,6 +114,17 @@ export const authRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." }); } + // Second-layer limit keyed on the resolved email, so a targeted + // attacker cannot exhaust reset attempts for a known user even if + // they cycle source IPs. + const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`); + if (!emailRl.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many password reset attempts. Please wait before trying again.", + }); + } + const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts index cb79559..764ce8e 100644 --- a/packages/api/src/router/invite.ts +++ b/packages/api/src/router/invite.ts @@ -86,12 +86,15 @@ export const inviteRouter = createTRPCRouter({ getInvite: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ ctx, input }) => { - const rl = await authRateLimiter(input.token); - if (!rl.allowed) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "Too many attempts. Please wait before trying again.", - }); + const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; + if (ipKey) { + const rl = await authRateLimiter(ipKey); + if (!rl.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many attempts. Please wait before trying again.", + }); + } } const invite = await ctx.db.inviteToken.findUnique({ @@ -115,12 +118,15 @@ export const inviteRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const rl = await authRateLimiter(input.token); - if (!rl.allowed) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "Too many attempts. Please wait before trying again.", - }); + const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; + if (ipKey) { + const rl = await authRateLimiter(ipKey); + if (!rl.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many attempts. Please wait before trying again.", + }); + } } const invite = await ctx.db.inviteToken.findUnique({ diff --git a/packages/api/src/router/user-self-service-procedure-support.ts b/packages/api/src/router/user-self-service-procedure-support.ts index fedb157..c53af50 100644 --- a/packages/api/src/router/user-self-service-procedure-support.ts +++ b/packages/api/src/router/user-self-service-procedure-support.ts @@ -1,8 +1,5 @@ import { Prisma } from "@capakraken/db"; -import { - dashboardLayoutSchema, - normalizeDashboardLayout, -} from "@capakraken/shared/schemas"; +import { dashboardLayoutSchema, normalizeDashboardLayout } from "@capakraken/shared/schemas"; import type { ColumnPreferences } from "@capakraken/shared/types"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -20,9 +17,20 @@ export const ToggleFavoriteProjectInputSchema = z.object({ }); export const SetColumnPreferencesInputSchema = z.object({ - view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]), + view: z.enum([ + "resources", + "projects", + "allocations", + "vacations", + "roles", + "users", + "blueprints", + ]), visible: z.array(z.string()).optional(), - sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(), + sort: z + .object({ field: z.string(), dir: z.enum(["asc", "desc"]) }) + .nullable() + .optional(), rowOrder: z.array(z.string()).nullable().optional(), }); @@ -36,7 +44,7 @@ export const VerifyTotpInputSchema = z.object({ }); type UserSelfServiceContext = Pick; -type UserPublicContext = Pick; +type UserPublicContext = Pick; export async function getCurrentUserProfile(ctx: UserSelfServiceContext) { return findUniqueOrThrow( @@ -61,9 +69,7 @@ export async function getDashboardLayout(ctx: UserSelfServiceContext) { select: { dashboardLayout: true, updatedAt: true }, }); - const normalized = user?.dashboardLayout - ? normalizeDashboardLayout(user.dashboardLayout) - : null; + const normalized = user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null; return { layout: normalized?.widgets.length ? normalized : null, updatedAt: user?.updatedAt ?? null, @@ -131,7 +137,9 @@ export async function setColumnPreferences( select: { columnPreferences: true }, }); const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences; - const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { visible: [] }; + const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { + visible: [], + }; const merged: import("@capakraken/shared").ViewPreferences = { visible: input.visible ?? prev.visible, @@ -183,13 +191,30 @@ export async function verifyAndEnableTotp( const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, - select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, - }) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>, + select: { + id: true, + name: true, + email: true, + totpSecret: true, + totpEnabled: true, + lastTotpAt: true, + }, + }) as Promise<{ + id: string; + name: string | null; + email: string; + totpSecret: string | null; + totpEnabled: boolean; + lastTotpAt: Date | null; + } | null>, "User", ); if (!user.totpSecret) { - throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No TOTP secret generated. Call generateTotpSecret first.", + }); } if (user.totpEnabled) { throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." }); @@ -211,11 +236,11 @@ export async function verifyAndEnableTotp( } // Replay-attack prevention: reject if the same 30-second window was already used - if ( - user.lastTotpAt != null && - Date.now() - user.lastTotpAt.getTime() < 30_000 - ) { - throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." }); + if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "TOTP code already used. Wait for the next code.", + }); } await (ctx.db.user.update as Function)({ @@ -241,16 +266,28 @@ export async function verifyTotp( ctx: UserPublicContext, input: z.infer, ) { - // Rate limit: max 10 attempts per 30 seconds per userId to prevent brute-force (A01-1) - const rl = await totpRateLimiter(input.userId); + // Rate limit keyed on BOTH userId and source IP. userId-only keying + // permits targeted user-lockout DoS; IP-only permits botnet bypass. + // Both buckets must allow for the attempt to proceed (CWE-307, A01-1). + const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : ""; + const totpKeys = ipKey ? [`user:${input.userId}`, ipKey] : [`user:${input.userId}`]; + const rl = await totpRateLimiter(totpKeys); if (!rl.allowed) { - throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." }); + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many TOTP attempts. Please wait before trying again.", + }); } - const user = await ctx.db.user.findUnique({ + const user = (await ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, - }) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null; + })) as { + id: string; + totpSecret: string | null; + totpEnabled: boolean; + lastTotpAt: Date | null; + } | null; // Generic error for both not-found and TOTP-not-enabled to prevent user enumeration if (!user || !user.totpEnabled || !user.totpSecret) { @@ -273,10 +310,7 @@ export async function verifyTotp( } // Replay-attack prevention: reject if the same 30-second window was already used - if ( - user.lastTotpAt != null && - Date.now() - user.lastTotpAt.getTime() < 30_000 - ) { + if (user.lastTotpAt != null && Date.now() - user.lastTotpAt.getTime() < 30_000) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }); } diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index b567042..1bbad97 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -19,6 +19,8 @@ export interface TRPCContext { dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null; roleDefaults: Record | null; requestId?: string; + /** Client IP extracted from X-Forwarded-For / X-Real-IP. Null if trust-proxy is off or header absent. */ + clientIp: string | null; } // Cache role defaults for 60 seconds to avoid DB hit on every request @@ -53,12 +55,14 @@ export function createTRPCContext(opts: { session: Session | null; dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null; roleDefaults?: Record | null; + clientIp?: string | null; }): TRPCContext { return { session: opts.session, db: prisma, dbUser: opts.dbUser ?? null, roleDefaults: opts.roleDefaults ?? null, + clientIp: opts.clientIp ?? null, }; } @@ -70,8 +74,7 @@ const t = initTRPC.context().create({ ...shape, data: { ...shape.data, - zodError: - error.cause instanceof ZodError ? error.cause.flatten() : null, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, @@ -147,34 +150,37 @@ if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "prod * Protected procedure — requires authenticated session AND a valid DB user record. * This prevents stale sessions from accessing data after the DB user is deleted. */ -export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => { - if (!ctx.session?.user) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); - } - if (!ctx.dbUser) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" }); - } - - // Rate limit by user ID - if (!isE2eTestMode) { - const rateLimitResult = await apiRateLimiter(ctx.dbUser.id); - if (!rateLimitResult.allowed) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`, - }); +export const protectedProcedure = t.procedure + .use(withPrismaErrors) + .use(withLogging) + .use(async ({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); + } + if (!ctx.dbUser) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" }); } - } - return next({ - ctx: { - ...ctx, - session: ctx.session, - user: ctx.session.user, - dbUser: ctx.dbUser, - }, + // Rate limit by user ID + if (!isE2eTestMode) { + const rateLimitResult = await apiRateLimiter(ctx.dbUser.id); + if (!rateLimitResult.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`, + }); + } + } + + return next({ + ctx: { + ...ctx, + session: ctx.session, + user: ctx.session.user, + dbUser: ctx.dbUser, + }, + }); }); -}); /** * Resource overview procedure — requires broad people-directory visibility. @@ -191,8 +197,8 @@ export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next }) ); if ( - !permissions.has(PermissionKey.VIEW_ALL_RESOURCES) - && !permissions.has(PermissionKey.MANAGE_RESOURCES) + !permissions.has(PermissionKey.VIEW_ALL_RESOURCES) && + !permissions.has(PermissionKey.MANAGE_RESOURCES) ) { throw new TRPCError({ code: "FORBIDDEN", @@ -280,7 +286,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { */ export function requirePermission( ctx: { permissions: Set }, - key: PermissionKey + key: PermissionKey, ): void { if (!ctx.permissions.has(key)) { throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });