fix(segurança): endurecer autenticação e rotas privadas

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:23 +00:00
parent ba369e8a83
commit e4c6a91350
12 changed files with 357 additions and 28 deletions

View File

@@ -5,6 +5,10 @@ import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
@@ -19,9 +23,20 @@ export async function GET(
);
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(
{ error: "Not found" },
{
status: 404,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const url = await createPresignedGetUrl(row.fileKey);
return NextResponse.json({ url });
return NextResponse.json(
{ url },
{
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}

View File

@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
});
export async function POST(request: Request) {
try {
await connection();
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);

View File

@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
@@ -10,16 +10,19 @@ interface RouteParams {
}
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
await connection();
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(

View File

@@ -1,19 +1,22 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
export async function GET() {
try {
await connection();
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Buscar tokens ativos do usuário
const activeTokens = await db
.select({

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(request: Request) {
const period = new URL(request.url).searchParams.get("period") ?? "";
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
if (!validatedPeriod.success) {
return NextResponse.json(
{
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const userId = await getUserId();
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ transactionId: string }> },
) {
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ seriesId: string }> },
) {
try {
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, {
headers: PRIVATE_RESPONSE_HEADERS,
});
} catch (error) {
console.error("Erro ao carregar histórico de antecipações:", error);
return NextResponse.json(
{
error: "Erro ao carregar histórico de antecipações.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
}

View File

@@ -131,6 +131,46 @@ export async function createInstallmentAnticipationAction(
const user = await getUser();
const data = createAnticipationSchema.parse(input);
if (data.payerId || data.categoryId) {
const [payer, category] = await Promise.all([
data.payerId
? db
.select({ id: payers.id })
.from(payers)
.where(
and(eq(payers.id, data.payerId), eq(payers.userId, user.id)),
)
.limit(1)
: Promise.resolve([]),
data.categoryId
? db
.select({ id: categories.id })
.from(categories)
.where(
and(
eq(categories.id, data.categoryId),
eq(categories.userId, user.id),
),
)
.limit(1)
: Promise.resolve([]),
]);
if (data.payerId && payer.length === 0) {
return {
success: false,
error: "Pagador inválido para esta conta.",
};
}
if (data.categoryId && category.length === 0) {
return {
success: false,
error: "Categoria inválida para esta conta.",
};
}
}
// 1. Validar parcelas selecionadas
const installments = await db.query.transactions.findMany({
where: and(

View File

@@ -0,0 +1,99 @@
import { and, desc, eq } from "drizzle-orm";
import {
categories,
installmentAnticipations,
payers,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
export type InstallmentAnticipationListItem = {
id: string;
anticipationPeriod: string;
anticipationDate: string;
installmentCount: number;
totalAmount: string;
discount: string;
transactionId: string;
note: string | null;
transaction: {
isSettled: boolean | null;
} | null;
payer: {
name: string;
} | null;
category: {
name: string;
} | null;
};
export async function fetchInstallmentAnticipations(
userId: string,
seriesId: string,
): Promise<InstallmentAnticipationListItem[]> {
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
const anticipations = await db
.select({
id: installmentAnticipations.id,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
installmentCount: installmentAnticipations.installmentCount,
totalAmount: installmentAnticipations.totalAmount,
discount: installmentAnticipations.discount,
transactionId: installmentAnticipations.transactionId,
note: installmentAnticipations.note,
transactionRecordId: transactions.id,
transactionIsSettled: transactions.isSettled,
payerName: payers.name,
categoryName: categories.name,
})
.from(installmentAnticipations)
.leftJoin(
transactions,
and(
eq(installmentAnticipations.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.leftJoin(
payers,
and(
eq(installmentAnticipations.payerId, payers.id),
eq(payers.userId, userId),
),
)
.leftJoin(
categories,
and(
eq(installmentAnticipations.categoryId, categories.id),
eq(categories.userId, userId),
),
)
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, userId),
),
)
.orderBy(desc(installmentAnticipations.createdAt));
return anticipations.map((anticipation) => ({
id: anticipation.id,
anticipationPeriod: anticipation.anticipationPeriod,
anticipationDate: anticipation.anticipationDate.toISOString(),
installmentCount: anticipation.installmentCount,
totalAmount: anticipation.totalAmount,
discount: anticipation.discount,
transactionId: anticipation.transactionId,
note: anticipation.note,
transaction: anticipation.transactionRecordId
? { isSettled: anticipation.transactionIsSettled }
: null,
payer: anticipation.payerName ? { name: anticipation.payerName } : null,
category: anticipation.categoryName
? { name: anticipation.categoryName }
: null,
}));
}

View File

@@ -0,0 +1,66 @@
import { and, eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export type TransactionAttachmentListItem = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: string;
url: string;
};
export async function fetchTransactionAttachments(
userId: string,
transactionId: string,
): Promise<TransactionAttachmentListItem[]> {
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
);
if (!transaction) {
return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
fileKey: attachments.fileKey,
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.where(eq(transactionAttachments.transactionId, transactionId));
return Promise.all(
rows.map(async (row) => ({
attachmentId: row.attachmentId,
fileName: row.fileName,
fileSize: row.fileSize,
mimeType: row.mimeType,
createdAt: row.createdAt.toISOString(),
url: await createPresignedGetUrl(row.fileKey),
})),
);
}

View File

@@ -33,6 +33,9 @@ export default async function proxy(request: NextRequest) {
const hostname = request.headers.get("host")?.replace(/:\d+$/, "");
if (publicDomain && hostname === publicDomain) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (pathname !== "/") {
return NextResponse.redirect(new URL("/", request.url));
}
@@ -67,6 +70,7 @@ export const config = {
// Apply middleware to protected and auth routes
matcher: [
"/",
"/api/:path*",
"/settings/:path*",
"/notes/:path*",
"/calendar/:path*",

View File

@@ -57,6 +57,16 @@ export const auth = betterAuth({
autoSignIn: true,
},
// Rate limiting
rateLimit: {
window: 60,
max: 100,
customRules: {
"/sign-in/email": { window: 60, max: 5 },
"/sign-up/email": { window: 60, max: 3 },
},
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",