Harden auth and mobile session handling
This commit is contained in:
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||||
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +13,24 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
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@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid email format" },
|
{ error: "Invalid email format" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -21,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email.toLowerCase()),
|
where: eq(users.email, normalizedEmail),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||||
import { resolveNewUserRole } from "~/lib/first-admin";
|
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||||
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
@@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
});
|
||||||
|
if (rateLimit) return rateLimit;
|
||||||
|
|
||||||
if (env.DISABLE_SIGNUPS === true) {
|
if (env.DISABLE_SIGNUPS === true) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "New account registration is currently disabled" },
|
{ 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 { firstName, lastName, email, password } = parsed.data;
|
||||||
const normalizedEmail = email.toLowerCase();
|
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({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, normalizedEmail),
|
where: eq(users.email, normalizedEmail),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "User with this email already exists" },
|
{ error: "Registration failed. Please check the form or sign in." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
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 { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
if (ipRateLimit) return ipRateLimit;
|
||||||
|
|
||||||
const { token, password } = (await request.json()) as {
|
const { token, password } = (await request.json()) as {
|
||||||
token: string;
|
token: string;
|
||||||
password: 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
|
// Find user with valid reset token that hasn't expired
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(users.resetToken, token),
|
eq(users.resetToken, tokenHash),
|
||||||
gt(users.resetTokenExpiry, new Date()),
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await revokeUserSessions(user.id);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
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 { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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 };
|
const { token } = (await request.json()) as { token: string };
|
||||||
|
|
||||||
if (!token || typeof token !== "string") {
|
if (!token || typeof token !== "string") {
|
||||||
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
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
|
// Find user with valid reset token that hasn't expired
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(users.resetToken, token),
|
eq(users.resetToken, tokenHash),
|
||||||
gt(users.resetTokenExpiry, new Date()),
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
|
|||||||
const authHeader = req.headers.get("authorization");
|
const authHeader = req.headers.get("authorization");
|
||||||
const secret = env.CRON_SECRET;
|
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 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface SignInFormProps {
|
interface SignInFormProps {
|
||||||
@@ -25,7 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
+4
-1
@@ -34,7 +34,10 @@ export const env = createEnv({
|
|||||||
.default("development"),
|
.default("development"),
|
||||||
DB_DISABLE_SSL: optionalEnvBoolean(),
|
DB_DISABLE_SSL: optionalEnvBoolean(),
|
||||||
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
|
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-compatible object storage (optional — local .data/receipts/ fallback when unset)
|
||||||
S3_ENDPOINT: z.string().url().optional(),
|
S3_ENDPOINT: z.string().url().optional(),
|
||||||
S3_BUCKET: z.string().optional(),
|
S3_BUCKET: z.string().optional(),
|
||||||
|
|||||||
+29
-3
@@ -1,10 +1,10 @@
|
|||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
export function hasSessionCookie(headers: Headers): boolean {
|
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
|
||||||
const cookie = headers.get("cookie") ?? "";
|
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
|
||||||
if (!cookie.trim()) return false;
|
|
||||||
|
|
||||||
|
function looksLikeSessionCookie(cookie: string): boolean {
|
||||||
return (
|
return (
|
||||||
cookie.includes("session_token=") ||
|
cookie.includes("session_token=") ||
|
||||||
cookie.includes("session_data=") ||
|
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) {
|
export async function getOptionalServerSession(headers: Headers) {
|
||||||
|
headers = headersWithAuthCookieFallback(headers);
|
||||||
|
|
||||||
if (!hasSessionCookie(headers)) {
|
if (!hasSessionCookie(headers)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-20
@@ -5,6 +5,7 @@ import { nextCookies } from "better-auth/next-js";
|
|||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||||
|
import { sendPasswordResetEmail } from "~/lib/password-reset";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/server/db/schema";
|
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.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
|
||||||
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
||||||
"beenvoice://",
|
"beenvoice://",
|
||||||
"exp://",
|
...(env.NODE_ENV === "development"
|
||||||
|
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
|
||||||
|
: []),
|
||||||
...(authentikOrigin ? [authentikOrigin] : []),
|
...(authentikOrigin ? [authentikOrigin] : []),
|
||||||
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
||||||
];
|
];
|
||||||
@@ -37,6 +40,29 @@ export const auth = betterAuth({
|
|||||||
advanced: {
|
advanced: {
|
||||||
trustedProxyHeaders: true,
|
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: {
|
experimental: {
|
||||||
joins: true,
|
joins: true,
|
||||||
},
|
},
|
||||||
@@ -61,25 +87,7 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: async (request) => {
|
trustedOrigins: staticTrustedOrigins,
|
||||||
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;
|
|
||||||
},
|
|
||||||
...(authentikEnabled && {
|
...(authentikEnabled && {
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -89,6 +97,16 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disableSignUp: signupsDisabled,
|
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: {
|
password: {
|
||||||
hash: async (password) => {
|
hash: async (password) => {
|
||||||
const bcrypt = await import("bcryptjs");
|
const bcrypt = await import("bcryptjs");
|
||||||
|
|||||||
+48
-32
@@ -1,10 +1,13 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import {
|
||||||
|
createPasswordResetToken,
|
||||||
|
hashPasswordResetToken,
|
||||||
|
} from "~/lib/reset-token";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -14,6 +17,45 @@ export type PasswordResetResult = {
|
|||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function sendPasswordResetEmail(input: {
|
||||||
|
userEmail: string;
|
||||||
|
userName?: string;
|
||||||
|
resetToken: string;
|
||||||
|
}): Promise<PasswordResetResult> {
|
||||||
|
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 <noreply@${fromDomain}>`,
|
||||||
|
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(
|
export async function sendPasswordResetForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<PasswordResetResult> {
|
): Promise<PasswordResetResult> {
|
||||||
@@ -26,44 +68,18 @@ export async function sendPasswordResetForUser(
|
|||||||
return { success: false, emailSent: false };
|
return { success: false, emailSent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
const resetToken = createPasswordResetToken();
|
||||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const resetTokenHash = hashPasswordResetToken(resetToken);
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ resetToken, resetTokenExpiry })
|
.set({ resetToken: resetTokenHash, resetTokenExpiry })
|
||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
if (!env.RESEND_API_KEY) {
|
return sendPasswordResetEmail({
|
||||||
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,
|
userEmail: user.email,
|
||||||
userName: user.name ?? undefined,
|
userName: user.name ?? undefined,
|
||||||
resetToken,
|
resetToken,
|
||||||
resetUrl,
|
|
||||||
expiryHours: 24,
|
|
||||||
});
|
});
|
||||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: `beenvoice <noreply@${fromDomain}>`,
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, RateLimitRecord>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+5
-1
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { isPublicRoute } from "~/lib/public-routes";
|
import { isPublicRoute } from "~/lib/public-routes";
|
||||||
|
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
@@ -26,7 +27,10 @@ export function proxy(request: NextRequest) {
|
|||||||
// If no session token, redirect to sign-in
|
// If no session token, redirect to sign-in
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
const signInUrl = new URL("/auth/signin", request.url);
|
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);
|
return NextResponse.redirect(signInUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,11 @@ import {
|
|||||||
getApiKeyDisplayPrefix,
|
getApiKeyDisplayPrefix,
|
||||||
hashApiKey,
|
hashApiKey,
|
||||||
} from "~/server/api/api-keys";
|
} 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";
|
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({
|
export const apiKeysRouter = createTRPCRouter({
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: sessionProcedure.query(async ({ ctx }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
return ctx.db.query.apiKeys.findMany({
|
return ctx.db.query.apiKeys.findMany({
|
||||||
where: eq(apiKeys.userId, ctx.session.user.id),
|
where: eq(apiKeys.userId, ctx.session.user.id),
|
||||||
columns: {
|
columns: {
|
||||||
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure
|
create: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
if (input.expiresAt && input.expiresAt <= new Date()) {
|
if (input.expiresAt && input.expiresAt <= new Date()) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
return { ...apiKey, key };
|
return { ...apiKey, key };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revoke: protectedProcedure
|
revoke: sessionProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [apiKey] = await ctx.db
|
const [apiKey] = await ctx.db
|
||||||
.update(apiKeys)
|
.update(apiKeys)
|
||||||
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revokeAll: protectedProcedure.mutation(async ({ ctx }) => {
|
revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(apiKeys)
|
.update(apiKeys)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Resend } from "resend";
|
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 { invoices, platformSettings } from "~/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const emailRouter = createTRPCRouter({
|
export const emailRouter = createTRPCRouter({
|
||||||
sendInvoice: protectedProcedure
|
sendInvoice: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
invoiceId: z.string(),
|
invoiceId: z.string(),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
sessionProcedure,
|
||||||
|
} from "../trpc";
|
||||||
import {
|
import {
|
||||||
invoices,
|
invoices,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
@@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return { success: true, deleted: ownedIds.length };
|
return { success: true, deleted: ownedIds.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
bulkImport: protectedProcedure
|
bulkImport: sessionProcedure
|
||||||
.input(bulkImportSchema)
|
.input(bulkImportSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
@@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// ── Public token (shareable link) ──────────────────────────────────────────
|
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||||
|
|
||||||
generatePublicToken: protectedProcedure
|
generatePublicToken: sessionProcedure
|
||||||
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return { token, expiresAt };
|
return { token, expiresAt };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revokePublicToken: protectedProcedure
|
revokePublicToken: sessionProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// ── Send reminder ──────────────────────────────────────────────────────────
|
// ── Send reminder ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
sendReminder: protectedProcedure
|
sendReminder: sessionProcedure
|
||||||
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
|
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
|
sessionProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { requireAdmin } from "~/server/api/require-admin";
|
import { requireAdmin } from "~/server/api/require-admin";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
|
import { revokeUserSessions } from "~/lib/session-security";
|
||||||
|
|
||||||
function resolveBusinessId(
|
function resolveBusinessId(
|
||||||
refs: { businessName?: string; businessNickname?: string },
|
refs: { businessName?: string; businessNickname?: string },
|
||||||
@@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Change user password
|
// Change user password
|
||||||
changePassword: protectedProcedure
|
changePassword: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await revokeUserSessions(userId, ctx.session.session?.token);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Export user data (backup)
|
// Export user data (backup)
|
||||||
exportData: protectedProcedure.query(async ({ ctx }) => {
|
exportData: sessionProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
@@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Import user data (restore)
|
// Import user data (restore)
|
||||||
importData: protectedProcedure
|
importData: sessionProcedure
|
||||||
.input(BackupDataSchema)
|
.input(BackupDataSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
@@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete all user data (for account deletion)
|
// Delete all user data (for account deletion)
|
||||||
deleteAllData: protectedProcedure
|
deleteAllData: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
||||||
|
|||||||
+45
-5
@@ -12,7 +12,11 @@ import superjson from "superjson";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "~/lib/auth";
|
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 { db } from "~/server/db";
|
||||||
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
|
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
|
* @see https://trpc.io/docs/server/context
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||||
const bearerToken = getBearerToken(opts.headers);
|
const headers = headersWithAuthCookieFallback(opts.headers);
|
||||||
|
const bearerToken = getBearerToken(headers);
|
||||||
|
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
|
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
|
||||||
@@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: "api-key" as const,
|
authSource: "api-key" as const,
|
||||||
apiKeyId: apiKeyAuth.apiKeyId,
|
apiKeyId: apiKeyAuth.apiKeyId,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasSessionCookie(opts.headers)) {
|
if (!hasSessionCookie(headers)) {
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
authSource: "none" as const,
|
authSource: "none" as const,
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: opts.headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: session?.user ? ("session" as const) : ("none" as const),
|
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[tRPC] Failed to resolve session:", error);
|
console.error("[tRPC] Failed to resolve session:", error);
|
||||||
@@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: "none" as const,
|
authSource: "none" as const,
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
|
|||||||
return result;
|
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
|
* 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
|
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||||
* are logged in.
|
* are logged in.
|
||||||
*/
|
*/
|
||||||
export const publicProcedure = t.procedure.use(timingMiddleware);
|
export const publicProcedure = t.procedure
|
||||||
|
.use(timingMiddleware)
|
||||||
|
.use(apiKeyRateLimitMiddleware);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected (authenticated) procedure
|
* 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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user