diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 99c95ac..88bb317 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { db } from "~/server/db"; import { users } from "~/server/db/schema"; import { sendPasswordResetForUser } from "~/lib/password-reset"; +import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit"; export async function POST(request: NextRequest) { try { @@ -12,8 +13,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Email is required" }, { status: 400 }); } + const normalizedEmail = email.toLowerCase().trim(); + const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), { + windowMs: 60 * 60 * 1000, + max: 10, + }); + if (ipRateLimit) return ipRateLimit; + + const emailRateLimit = requireRateLimit( + rateLimitKey(request, "auth:forgot-email", normalizedEmail), + { + windowMs: 60 * 60 * 1000, + max: 3, + }, + ); + if (emailRateLimit) return emailRateLimit; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { + if (!emailRegex.test(normalizedEmail)) { return NextResponse.json( { error: "Invalid email format" }, { status: 400 }, @@ -21,7 +38,7 @@ export async function POST(request: NextRequest) { } const user = await db.query.users.findFirst({ - where: eq(users.email, email.toLowerCase()), + where: eq(users.email, normalizedEmail), columns: { id: true }, }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 6e6d3ae..268ae00 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { auth } from "~/lib/auth"; import { getDatabaseSetupErrorMessage } from "~/lib/db-errors"; import { resolveNewUserRole } from "~/lib/first-admin"; +import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit"; import { env } from "~/env"; import { db } from "~/server/db"; import { accounts, users } from "~/server/db/schema"; @@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string { export async function POST(request: NextRequest) { try { + const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), { + windowMs: 60 * 60 * 1000, + max: 5, + }); + if (rateLimit) return rateLimit; + if (env.DISABLE_SIGNUPS === true) { return NextResponse.json( { error: "New account registration is currently disabled" }, @@ -106,13 +113,22 @@ export async function POST(request: NextRequest) { const { firstName, lastName, email, password } = parsed.data; const normalizedEmail = email.toLowerCase(); + const emailRateLimit = requireRateLimit( + rateLimitKey(request, "auth:register-email", normalizedEmail), + { + windowMs: 60 * 60 * 1000, + max: 3, + }, + ); + if (emailRateLimit) return emailRateLimit; + const existingUser = await db.query.users.findFirst({ where: eq(users.email, normalizedEmail), }); if (existingUser) { return NextResponse.json( - { error: "User with this email already exists" }, + { error: "Registration failed. Please check the form or sign in." }, { status: 400 }, ); } diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 7d1f08b..25c5606 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,11 +1,20 @@ import { type NextRequest, NextResponse } from "next/server"; import { eq, and, gt } from "drizzle-orm"; import bcrypt from "bcryptjs"; +import { hashPasswordResetToken } from "~/lib/reset-token"; +import { revokeUserSessions } from "~/lib/session-security"; +import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit"; import { db } from "~/server/db"; import { accounts, users } from "~/server/db/schema"; export async function POST(request: NextRequest) { try { + const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), { + windowMs: 60 * 1000, + max: 10, + }); + if (ipRateLimit) return ipRateLimit; + const { token, password } = (await request.json()) as { token: string; password: string; @@ -29,10 +38,21 @@ export async function POST(request: NextRequest) { ); } + const tokenRateLimit = requireRateLimit( + rateLimitKey(request, "auth:reset-token", token), + { + windowMs: 60 * 60 * 1000, + max: 5, + }, + ); + if (tokenRateLimit) return tokenRateLimit; + + const tokenHash = hashPasswordResetToken(token); + // Find user with valid reset token that hasn't expired const user = await db.query.users.findFirst({ where: and( - eq(users.resetToken, token), + eq(users.resetToken, tokenHash), gt(users.resetTokenExpiry, new Date()), ), }); @@ -82,6 +102,8 @@ export async function POST(request: NextRequest) { } }); + await revokeUserSessions(user.id); + return NextResponse.json( { success: true, diff --git a/src/app/api/auth/validate-reset-token/route.ts b/src/app/api/auth/validate-reset-token/route.ts index 4609646..4afbc4d 100644 --- a/src/app/api/auth/validate-reset-token/route.ts +++ b/src/app/api/auth/validate-reset-token/route.ts @@ -1,20 +1,39 @@ import { type NextRequest, NextResponse } from "next/server"; import { eq, and, gt } from "drizzle-orm"; +import { hashPasswordResetToken } from "~/lib/reset-token"; +import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit"; import { db } from "~/server/db"; import { users } from "~/server/db/schema"; export async function POST(request: NextRequest) { try { + const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), { + windowMs: 60 * 1000, + max: 20, + }); + if (ipRateLimit) return ipRateLimit; + const { token } = (await request.json()) as { token: string }; if (!token || typeof token !== "string") { return NextResponse.json({ error: "Token is required" }, { status: 400 }); } + const tokenRateLimit = requireRateLimit( + rateLimitKey(request, "auth:validate-reset-token", token), + { + windowMs: 60 * 60 * 1000, + max: 5, + }, + ); + if (tokenRateLimit) return tokenRateLimit; + + const tokenHash = hashPasswordResetToken(token); + // Find user with valid reset token that hasn't expired const user = await db.query.users.findFirst({ where: and( - eq(users.resetToken, token), + eq(users.resetToken, tokenHash), gt(users.resetTokenExpiry, new Date()), ), }); diff --git a/src/app/api/cron/generate-recurring/route.ts b/src/app/api/cron/generate-recurring/route.ts index 255226a..ea95527 100644 --- a/src/app/api/cron/generate-recurring/route.ts +++ b/src/app/api/cron/generate-recurring/route.ts @@ -7,7 +7,14 @@ export async function POST(req: NextRequest) { const authHeader = req.headers.get("authorization"); const secret = env.CRON_SECRET; - if (secret && authHeader !== `Bearer ${secret}`) { + if (!secret) { + return NextResponse.json( + { error: "Cron secret is not configured" }, + { status: 500 }, + ); + } + + if (authHeader !== `Bearer ${secret}`) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index 6d3cb92..141c1cb 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { env } from "~/env"; import { authClient } from "~/lib/auth-client"; +import { safeCallbackPath } from "~/lib/safe-callback-url"; import { toast } from "sonner"; interface SignInFormProps { @@ -25,7 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) { const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const router = useRouter(); const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl")); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); diff --git a/src/env.js b/src/env.js index 1897c30..90b6546 100644 --- a/src/env.js +++ b/src/env.js @@ -34,7 +34,10 @@ export const env = createEnv({ .default("development"), DB_DISABLE_SSL: optionalEnvBoolean(), DISABLE_SIGNUPS: optionalEnvBoolean().default(true), - CRON_SECRET: z.string().optional(), + CRON_SECRET: + process.env.NODE_ENV === "production" + ? z.string().min(32) + : z.string().optional(), // S3-compatible object storage (optional — local .data/receipts/ fallback when unset) S3_ENDPOINT: z.string().url().optional(), S3_BUCKET: z.string().optional(), diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index 8ecb793..9384a02 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -1,10 +1,10 @@ import { headers as nextHeaders } from "next/headers"; import { auth } from "~/lib/auth"; -export function hasSessionCookie(headers: Headers): boolean { - const cookie = headers.get("cookie") ?? ""; - if (!cookie.trim()) return false; +const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie"; +const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024; +function looksLikeSessionCookie(cookie: string): boolean { return ( cookie.includes("session_token=") || cookie.includes("session_data=") || @@ -13,7 +13,33 @@ export function hasSessionCookie(headers: Headers): boolean { ); } +export function headersWithAuthCookieFallback(headers: Headers): Headers { + if (headers.get("cookie")?.trim()) return headers; + + const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim(); + if ( + !mobileCookie || + mobileCookie.length > MAX_AUTH_COOKIE_HEADER_LENGTH || + !looksLikeSessionCookie(mobileCookie) + ) { + return headers; + } + + const nextHeaders = new Headers(headers); + nextHeaders.set("cookie", mobileCookie); + return nextHeaders; +} + +export function hasSessionCookie(headers: Headers): boolean { + const cookie = headers.get("cookie") ?? ""; + if (!cookie.trim()) return false; + + return looksLikeSessionCookie(cookie); +} + export async function getOptionalServerSession(headers: Headers) { + headers = headersWithAuthCookieFallback(headers); + if (!hasSessionCookie(headers)) { return null; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 15d73ba..ba952d8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,6 +5,7 @@ import { nextCookies } from "better-auth/next-js"; import { genericOAuth } from "better-auth/plugins"; import { env } from "~/env"; import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin"; +import { sendPasswordResetEmail } from "~/lib/password-reset"; import { db } from "~/server/db"; import * as schema from "~/server/db/schema"; @@ -26,7 +27,9 @@ const staticTrustedOrigins = [ ...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []), ...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []), "beenvoice://", - "exp://", + ...(env.NODE_ENV === "development" + ? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"] + : []), ...(authentikOrigin ? [authentikOrigin] : []), ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []), ]; @@ -37,6 +40,29 @@ export const auth = betterAuth({ advanced: { trustedProxyHeaders: true, }, + rateLimit: { + enabled: true, + window: 60, + max: 100, + customRules: { + "/sign-in/email": { + window: 60, + max: 10, + }, + "/sign-up/email": { + window: 60 * 60, + max: 5, + }, + "/request-password-reset": { + window: 60 * 60, + max: 5, + }, + "/reset-password": { + window: 60, + max: 10, + }, + }, + }, experimental: { joins: true, }, @@ -61,25 +87,7 @@ export const auth = betterAuth({ }, }, }, - trustedOrigins: async (request) => { - const origins = [...staticTrustedOrigins]; - - if (!request) return origins; - - const origin = request.headers.get("origin"); - if (origin) origins.push(origin); - - const forwardedHost = request.headers.get("x-forwarded-host"); - const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https"; - if (forwardedHost) { - for (const host of forwardedHost.split(",")) { - const trimmed = host.trim(); - if (trimmed) origins.push(`${forwardedProto}://${trimmed}`); - } - } - - return origins; - }, + trustedOrigins: staticTrustedOrigins, ...(authentikEnabled && { accountLinking: { enabled: true, @@ -89,6 +97,16 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, disableSignUp: signupsDisabled, + minPasswordLength: 8, + resetPasswordTokenExpiresIn: 60 * 60, + revokeSessionsOnPasswordReset: true, + sendResetPassword: async ({ user, token }) => { + await sendPasswordResetEmail({ + userEmail: user.email, + userName: user.name ?? undefined, + resetToken: token, + }); + }, password: { hash: async (password) => { const bcrypt = await import("bcryptjs"); diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts index aaac19d..7c75060 100644 --- a/src/lib/password-reset.ts +++ b/src/lib/password-reset.ts @@ -1,10 +1,13 @@ -import crypto from "crypto"; import { eq } from "drizzle-orm"; import { Resend } from "resend"; import { env } from "~/env"; import { APP_EMAIL_DOMAIN } from "~/lib/app-email"; import { getAppUrl } from "~/lib/app-url"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; +import { + createPasswordResetToken, + hashPasswordResetToken, +} from "~/lib/reset-token"; import { db } from "~/server/db"; import { users } from "~/server/db/schema"; @@ -14,6 +17,45 @@ export type PasswordResetResult = { userEmail?: string; }; +export async function sendPasswordResetEmail(input: { + userEmail: string; + userName?: string; + resetToken: string; +}): Promise { + if (!env.RESEND_API_KEY) { + console.warn( + "Password reset requested, but RESEND_API_KEY is not configured.", + ); + return { success: true, emailSent: false, userEmail: input.userEmail }; + } + + try { + const resend = new Resend(env.RESEND_API_KEY); + const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`; + const emailTemplate = generatePasswordResetEmailTemplate({ + userEmail: input.userEmail, + userName: input.userName, + resetToken: input.resetToken, + resetUrl, + expiryHours: 1, + }); + const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN; + + await resend.emails.send({ + from: `beenvoice `, + to: input.userEmail, + subject: emailTemplate.subject, + html: emailTemplate.html, + text: emailTemplate.text, + }); + + return { success: true, emailSent: true, userEmail: input.userEmail }; + } catch (emailError) { + console.error("Failed to send password reset email:", emailError); + return { success: true, emailSent: false, userEmail: input.userEmail }; + } +} + export async function sendPasswordResetForUser( userId: string, ): Promise { @@ -26,44 +68,18 @@ export async function sendPasswordResetForUser( return { success: false, emailSent: false }; } - const resetToken = crypto.randomBytes(32).toString("hex"); - const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); + const resetToken = createPasswordResetToken(); + const resetTokenHash = hashPasswordResetToken(resetToken); + const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); await db .update(users) - .set({ resetToken, resetTokenExpiry }) + .set({ resetToken: resetTokenHash, resetTokenExpiry }) .where(eq(users.id, user.id)); - if (!env.RESEND_API_KEY) { - console.warn( - "Password reset requested, but RESEND_API_KEY is not configured.", - ); - return { success: true, emailSent: false, userEmail: user.email }; - } - - try { - const resend = new Resend(env.RESEND_API_KEY); - const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`; - const emailTemplate = generatePasswordResetEmailTemplate({ - userEmail: user.email, - userName: user.name ?? undefined, - resetToken, - resetUrl, - expiryHours: 24, - }); - const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN; - - await resend.emails.send({ - from: `beenvoice `, - to: user.email, - subject: emailTemplate.subject, - html: emailTemplate.html, - text: emailTemplate.text, - }); - - return { success: true, emailSent: true, userEmail: user.email }; - } catch (emailError) { - console.error("Failed to send password reset email:", emailError); - return { success: true, emailSent: false, userEmail: user.email }; - } + return sendPasswordResetEmail({ + userEmail: user.email, + userName: user.name ?? undefined, + resetToken, + }); } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..ded75e2 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { NextResponse, type NextRequest } from "next/server"; + +type RateLimitRule = { + windowMs: number; + max: number; +}; + +type RateLimitRecord = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +function clientIp(request: NextRequest) { + return ( + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-real-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown" + ); +} + +export function hashRateLimitPart(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +export function rateLimitKey(request: NextRequest, scope: string, subject?: string) { + const parts = [scope, clientIp(request)]; + if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim())); + return parts.join(":"); +} + +function retryAfterSeconds(resetAt: number) { + return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000)); +} + +export function checkRateLimit(key: string, rule: RateLimitRule) { + const now = Date.now(); + const existing = buckets.get(key); + + if (!existing || existing.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + rule.windowMs }); + return { allowed: true, retryAfter: 0 }; + } + + existing.count += 1; + if (existing.count <= rule.max) { + return { allowed: true, retryAfter: 0 }; + } + + return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) }; +} + +export function rateLimitResponse(retryAfter: number) { + return NextResponse.json( + { error: "Too many attempts. Please wait and try again." }, + { + status: 429, + headers: { + "Retry-After": String(retryAfter), + "X-RateLimit-Retry-After": String(retryAfter), + }, + }, + ); +} + +export function requireRateLimit(key: string, rule: RateLimitRule) { + const result = checkRateLimit(key, rule); + return result.allowed ? null : rateLimitResponse(result.retryAfter); +} diff --git a/src/lib/reset-token.ts b/src/lib/reset-token.ts new file mode 100644 index 0000000..9d2216c --- /dev/null +++ b/src/lib/reset-token.ts @@ -0,0 +1,9 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function createPasswordResetToken() { + return randomBytes(32).toString("hex"); +} + +export function hashPasswordResetToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/src/lib/safe-callback-url.ts b/src/lib/safe-callback-url.ts new file mode 100644 index 0000000..391c0cd --- /dev/null +++ b/src/lib/safe-callback-url.ts @@ -0,0 +1,23 @@ +const FALLBACK_CALLBACK_PATH = "/dashboard"; + +export function safeCallbackPath(value: string | null | undefined) { + if (!value) return FALLBACK_CALLBACK_PATH; + + const trimmed = value.trim(); + if ( + !trimmed.startsWith("/") || + trimmed.startsWith("//") || + trimmed.includes("\\") || + /[\u0000-\u001f\u007f]/.test(trimmed) + ) { + return FALLBACK_CALLBACK_PATH; + } + + try { + const url = new URL(trimmed, "https://beenvoice.local"); + if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return FALLBACK_CALLBACK_PATH; + } +} diff --git a/src/lib/session-security.ts b/src/lib/session-security.ts new file mode 100644 index 0000000..633e9a6 --- /dev/null +++ b/src/lib/session-security.ts @@ -0,0 +1,12 @@ +import { and, eq, ne } from "drizzle-orm"; + +import { db } from "~/server/db"; +import { sessions } from "~/server/db/schema"; + +export async function revokeUserSessions(userId: string, exceptToken?: string | null) { + const condition = exceptToken + ? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken)) + : eq(sessions.userId, userId); + + await db.delete(sessions).where(condition); +} diff --git a/src/proxy.ts b/src/proxy.ts index 8ffa356..95b2eb5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { isPublicRoute } from "~/lib/public-routes"; +import { safeCallbackPath } from "~/lib/safe-callback-url"; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -26,7 +27,10 @@ export function proxy(request: NextRequest) { // If no session token, redirect to sign-in if (!sessionToken) { const signInUrl = new URL("/auth/signin", request.url); - signInUrl.searchParams.set("callbackUrl", request.url); + signInUrl.searchParams.set( + "callbackUrl", + safeCallbackPath(`${request.nextUrl.pathname}${request.nextUrl.search}`), + ); return NextResponse.redirect(signInUrl); } diff --git a/src/server/api/routers/apiKeys.ts b/src/server/api/routers/apiKeys.ts index 90e78c8..73594e6 100644 --- a/src/server/api/routers/apiKeys.ts +++ b/src/server/api/routers/apiKeys.ts @@ -7,22 +7,11 @@ import { getApiKeyDisplayPrefix, hashApiKey, } from "~/server/api/api-keys"; -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc"; import { apiKeys } from "~/server/db/schema"; -function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) { - if (ctx.authSource !== "session") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "API keys can only be managed from an authenticated session", - }); - } -} - export const apiKeysRouter = createTRPCRouter({ - list: protectedProcedure.query(async ({ ctx }) => { - requireSessionAuth(ctx); - + list: sessionProcedure.query(async ({ ctx }) => { return ctx.db.query.apiKeys.findMany({ where: eq(apiKeys.userId, ctx.session.user.id), columns: { @@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({ }); }), - create: protectedProcedure + create: sessionProcedure .input( z.object({ name: z.string().trim().min(1).max(100), @@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - requireSessionAuth(ctx); - if (input.expiresAt && input.expiresAt <= new Date()) { throw new TRPCError({ code: "BAD_REQUEST", @@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({ return { ...apiKey, key }; }), - revoke: protectedProcedure + revoke: sessionProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - requireSessionAuth(ctx); - const now = new Date(); const [apiKey] = await ctx.db .update(apiKeys) @@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({ return { success: true }; }), - revokeAll: protectedProcedure.mutation(async ({ ctx }) => { - requireSessionAuth(ctx); - + revokeAll: sessionProcedure.mutation(async ({ ctx }) => { const now = new Date(); await ctx.db .update(apiKeys) diff --git a/src/server/api/routers/email.ts b/src/server/api/routers/email.ts index 1c0296b..0c732b9 100644 --- a/src/server/api/routers/email.ts +++ b/src/server/api/routers/email.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { Resend } from "resend"; -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc"; import { invoices, platformSettings } from "~/server/db/schema"; import { eq } from "drizzle-orm"; import { env } from "~/env"; @@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) { } export const emailRouter = createTRPCRouter({ - sendInvoice: protectedProcedure + sendInvoice: sessionProcedure .input( z.object({ invoiceId: z.string(), diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index 8bff0cf..4f57da3 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -1,6 +1,11 @@ import { z } from "zod"; import { and, desc, eq, inArray } from "drizzle-orm"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, + sessionProcedure, +} from "../trpc"; import { invoices, invoiceItems, @@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({ return { success: true, deleted: ownedIds.length }; }), - bulkImport: protectedProcedure + bulkImport: sessionProcedure .input(bulkImportSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; @@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({ // ── Public token (shareable link) ────────────────────────────────────────── - generatePublicToken: protectedProcedure + generatePublicToken: sessionProcedure .input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) .mutation(async ({ ctx, input }) => { const invoice = await ctx.db.query.invoices.findFirst({ @@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({ return { token, expiresAt }; }), - revokePublicToken: protectedProcedure + revokePublicToken: sessionProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const invoice = await ctx.db.query.invoices.findFirst({ @@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({ // ── Send reminder ────────────────────────────────────────────────────────── - sendReminder: protectedProcedure + sendReminder: sessionProcedure .input(z.object({ id: z.string(), customMessage: z.string().optional() })) .mutation(async ({ ctx, input }) => { const invoice = await ctx.db.query.invoices.findFirst({ diff --git a/src/server/api/routers/settings.ts b/src/server/api/routers/settings.ts index 13aaf10..c0a612f 100644 --- a/src/server/api/routers/settings.ts +++ b/src/server/api/routers/settings.ts @@ -7,6 +7,7 @@ import { createTRPCRouter, protectedProcedure, publicProcedure, + sessionProcedure, } from "~/server/api/trpc"; import { requireAdmin } from "~/server/api/require-admin"; import { @@ -32,6 +33,7 @@ import { pdfTemplateSchema, type ColorMode, } from "~/lib/branding"; +import { revokeUserSessions } from "~/lib/session-security"; function resolveBusinessId( refs: { businessName?: string; businessNickname?: string }, @@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({ }), // Change user password - changePassword: protectedProcedure + changePassword: sessionProcedure .input( z .object({ @@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({ } }); + await revokeUserSessions(userId, ctx.session.session?.token); + return { success: true }; }), // Export user data (backup) - exportData: protectedProcedure.query(async ({ ctx }) => { + exportData: sessionProcedure.query(async ({ ctx }) => { const userId = ctx.session.user.id; const user = await ctx.db.query.users.findFirst({ @@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({ }), // Import user data (restore) - importData: protectedProcedure + importData: sessionProcedure .input(BackupDataSchema) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; @@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({ }), // Delete all user data (for account deletion) - deleteAllData: protectedProcedure + deleteAllData: sessionProcedure .input( z.object({ confirmText: z.string().refine((val) => val === "DELETE ALL DATA", { diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index e1c2b7f..8e94c55 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -12,7 +12,11 @@ import superjson from "superjson"; import { ZodError } from "zod"; import { auth } from "~/lib/auth"; -import { hasSessionCookie } from "~/lib/auth-server"; +import { + hasSessionCookie, + headersWithAuthCookieFallback, +} from "~/lib/auth-server"; +import { checkRateLimit } from "~/lib/rate-limit"; import { db } from "~/server/db"; import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys"; @@ -29,7 +33,8 @@ import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys"; * @see https://trpc.io/docs/server/context */ export const createTRPCContext = async (opts: { headers: Headers }) => { - const bearerToken = getBearerToken(opts.headers); + const headers = headersWithAuthCookieFallback(opts.headers); + const bearerToken = getBearerToken(headers); if (bearerToken) { const apiKeyAuth = await getUserForApiKey(db, bearerToken); @@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { authSource: "api-key" as const, apiKeyId: apiKeyAuth.apiKeyId, ...opts, + headers, }; } } - if (!hasSessionCookie(opts.headers)) { + if (!hasSessionCookie(headers)) { return { db, session: null, authSource: "none" as const, apiKeyId: null, ...opts, + headers, }; } try { const session = await auth.api.getSession({ - headers: opts.headers, + headers, }); return { @@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { authSource: session?.user ? ("session" as const) : ("none" as const), apiKeyId: null, ...opts, + headers, }; } catch (error) { console.error("[tRPC] Failed to resolve session:", error); @@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { authSource: "none" as const, apiKeyId: null, ...opts, + headers, }; } }; @@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { return result; }); +const apiKeyRateLimitMiddleware = t.middleware(({ ctx, next }) => { + if (ctx.authSource === "api-key" && ctx.apiKeyId) { + const result = checkRateLimit(`trpc:api-key:${ctx.apiKeyId}`, { + windowMs: 60 * 1000, + max: 120, + }); + + if (!result.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "API key rate limit exceeded. Please try again later.", + }); + } + } + + return next(); +}); + /** * Public (unauthenticated) procedure * @@ -150,7 +177,9 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { * 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.use(timingMiddleware); +export const publicProcedure = t.procedure + .use(timingMiddleware) + .use(apiKeyRateLimitMiddleware); /** * Protected (authenticated) procedure @@ -173,3 +202,14 @@ export const protectedProcedure = t.procedure }, }); }); + +export const sessionProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.authSource !== "session") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This action requires an authenticated browser or app session", + }); + } + + return next(); +});