Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ZodError } from "zod";

export type ErrorCode =
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "INTERNAL_ERROR";

export type ErrorDetails = Record<string, unknown>;

export class AppError extends Error {
readonly code: ErrorCode;
readonly statusCode: number;
readonly details?: ErrorDetails;

constructor(
code: ErrorCode,
message: string,
statusCode: number,
details?: ErrorDetails,
) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.details = details;
}

static validation(
message = "Dados inválidos",
details?: ErrorDetails,
): AppError {
return new AppError("VALIDATION_ERROR", message, 400, details);
}

static unauthorized(message = "Não autenticado."): AppError {
return new AppError("UNAUTHORIZED", message, 401);
}

static forbidden(message = "Acesso negado."): AppError {
return new AppError("FORBIDDEN", message, 403);
}

static notFound(message = "Recurso não encontrado."): AppError {
return new AppError("NOT_FOUND", message, 404);
}

static conflict(message: string, details?: ErrorDetails): AppError {
return new AppError("CONFLICT", message, 409, details);
}

static internal(
message = "Erro interno.",
details?: ErrorDetails,
): AppError {
return new AppError("INTERNAL_ERROR", message, 500, details);
}

static fromZodError(
error: ZodError,
message = "Dados inválidos",
): AppError {
return AppError.validation(message, error.flatten().fieldErrors);
}

toJSON(): { code: ErrorCode; message: string; details?: ErrorDetails } {
const body: { code: ErrorCode; message: string; details?: ErrorDetails } = {
code: this.code,
message: this.message,
};
if (this.details !== undefined) {
body.details = this.details;
}
return body;
}
}

export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
27 changes: 25 additions & 2 deletions backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
import { AppError } from "../lib/errors";

function isProduction(): boolean {
return process.env.NODE_ENV === "production";
}

export function errorHandler(
error: Error,
Expand All @@ -7,8 +13,25 @@ export function errorHandler(
_next: NextFunction,
Comment on lines 9 to 13
): void {
if (error.message.startsWith("Origin not allowed by CORS")) {
res.status(403).json({ message: "Origem não permitida." });
const appError = AppError.forbidden("Origem não permitida.");
res.status(appError.statusCode).json(appError.toJSON());
return;
}

if (error instanceof AppError) {
res.status(error.statusCode).json(error.toJSON());
return;
}
res.status(500).json({ message: "Erro interno.", error: error.message });

if (error instanceof ZodError) {
const appError = AppError.fromZodError(error);
res.status(appError.statusCode).json(appError.toJSON());
return;
}

const details = isProduction()
? undefined
: { cause: error.message || "unknown" };
const appError = AppError.internal("Erro interno.", details);
Comment on lines +32 to +35
res.status(appError.statusCode).json(appError.toJSON());
}
4 changes: 3 additions & 1 deletion backend/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { NextFunction, Request, Response } from "express";
import { AppError } from "../lib/errors";

export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.session?.userId) {
return res.status(401).json({ message: "Não autenticado." });
const error = AppError.unauthorized();
return res.status(error.statusCode).json(error.toJSON());
}
next();
}
45 changes: 45 additions & 0 deletions backend/src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
import type { ZodType } from "zod";
import { ZodError } from "zod";
import { AppError } from "../lib/errors";

type ValidateSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};

export function validate(schemas: ValidateSchemas): RequestHandler {
return (req: Request, _res: Response, next: NextFunction) => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body);
}
if (schemas.query) {
const parsed = schemas.query.parse(req.query);
Object.defineProperty(req, "query", {
value: parsed,
writable: true,
configurable: true,
enumerable: true,
});
}
if (schemas.params) {
const parsed = schemas.params.parse(req.params);
Object.defineProperty(req, "params", {
value: parsed,
writable: true,
configurable: true,
enumerable: true,
});
}
next();
} catch (error) {
if (error instanceof ZodError) {
next(AppError.fromZodError(error));
return;
}
next(error);
}
};
}
39 changes: 10 additions & 29 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,21 @@
import { randomBytes } from "crypto";
import { Request, Response } from "express";
import { z } from "zod";
import {
AuthCallbackParamsSchema,
OAuthProviderSchema,
} from "../types/auth.types.js";

import type { OAuthProvider } from "../types/auth.types.js";
import { AuthCallbackParamsSchema } from "../types/auth.types.js";
import { AuthService } from "./auth.service.js";

export class AuthController {
constructor(private readonly authService: AuthService) {}

async getUrl(req: Request, res: Response) {
try {
const provider = OAuthProviderSchema.parse(req.params.provider);

const state = randomBytes(16).toString("hex");

(req.session as any).oauth_state = state;
await req.session.save();
const provider = req.params.provider as OAuthProvider;
const state = randomBytes(16).toString("hex");

const url = await this.authService.getAuthUrl(provider, state);
(req.session as { oauth_state?: string }).oauth_state = state;
await req.session.save();

return res.json({ url });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: "Provider inválido",
details: error.message,
});
}

return res.status(400).json({
error: (error as Error).message,
});
}
const url = await this.authService.getAuthUrl(provider, state);
return res.json({ url });
}

async callback(req: Request, res: Response) {
Expand All @@ -50,7 +31,7 @@ export class AuthController {
callbackUrl,
});

const oauthState = (req.session as any).oauth_state;
const oauthState = (req.session as { oauth_state?: string }).oauth_state;

if (!oauthState) {
return res.redirect(`${frontendUrl}/login?error=oauth_state_missing`);
Expand All @@ -60,7 +41,7 @@ export class AuthController {
return res.redirect(`${frontendUrl}/login?error=oauth_state_invalid`);
}

delete (req.session as any).oauth_state;
delete (req.session as { oauth_state?: string }).oauth_state;

const result = await this.authService.handleCallback({
...params,
Expand Down
49 changes: 15 additions & 34 deletions backend/src/modules/auth/credentials.controller.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
import { Request, Response } from "express";
import { z } from "zod";
import { LoginSchema, RegisterSchema } from "../types/credentials.types";
import { AppError } from "../../lib/errors";
import { CredentialsService } from "./credentials.service";

export class CredentialsController {
constructor(private readonly service: CredentialsService) {}

async register(req: Request, res: Response) {
try {
const input = RegisterSchema.parse(req.body);
const { user, session: userSession } = await this.service.register(input);
const { user, session: userSession } = await this.service.register(
req.body,
);

req.session.userId = userSession.userId;
req.session.role = userSession.role;
await req.session.save();
req.session.userId = userSession.userId;
req.session.role = userSession.role;
await req.session.save();

return res.status(201).json({ user, session: userSession });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: error.format() });
}
const message = error instanceof Error ? error.message : "Erro interno";
const status = message === "Email já cadastrado" ? 409 : 500;
return res.status(status).json({ error: message });
}
return res.status(201).json({ user, session: userSession });
}

async login(req: Request, res: Response) {
try {
const input = LoginSchema.parse(req.body);
const { user, session: userSession } = await this.service.login(input);
const { user, session: userSession } = await this.service.login(req.body);

req.session.userId = userSession.userId;
req.session.role = userSession.role;
await req.session.save();
req.session.userId = userSession.userId;
req.session.role = userSession.role;
await req.session.save();

return res.json({ user, session: userSession });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: error.format() });
}
const message = error instanceof Error ? error.message : "Erro interno";
const status = message === "Credenciais inválidas" ? 401 : 500;
return res.status(status).json({ error: message });
}
return res.json({ user, session: userSession });
}

async logout(req: Request, res: Response) {
Expand All @@ -53,13 +34,13 @@ export class CredentialsController {

async me(req: Request, res: Response) {
if (!req.session.userId) {
return res.status(401).json({ error: "Não autenticado" });
throw AppError.unauthorized();
}

const user = await this.service.findById(req.session.userId);
if (!user) {
await req.session.destroy();
return res.status(401).json({ error: "Não autenticado" });
throw AppError.unauthorized();
}

return res.json({ user });
Expand Down
20 changes: 15 additions & 5 deletions backend/src/modules/auth/credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ export class CredentialsService {
const existingCredential = await db.query.credentials.findFirst({
where: eq(credentials.email, email),
});
if (existingCredential) throw new Error("Email já cadastrado");
if (existingCredential) {
throw AppError.conflict("Email já cadastrado");
}

const existingUser = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingUser) throw new Error("Email já cadastrado");
if (existingUser) {
throw AppError.conflict("Email já cadastrado");
}

const passwordHash = await argon2.hash(password, argonOptions);

Expand Down Expand Up @@ -77,15 +81,21 @@ export class CredentialsService {
const credential = await db.query.credentials.findFirst({
where: eq(credentials.email, email),
});
if (!credential) throw new Error("Credenciais inválidas");
if (!credential) {
throw AppError.unauthorized("Credenciais inválidas");
}

const valid = await argon2.verify(credential.passwordHash, password);
if (!valid) throw new Error("Credenciais inválidas");
if (!valid) {
throw AppError.unauthorized("Credenciais inválidas");
}

const user = await db.query.users.findFirst({
where: eq(users.id, credential.userId),
});
if (!user) throw new Error("Usuário não encontrado");
if (!user) {
throw AppError.notFound("Usuário não encontrado");
}

return { user, session: { userId: user.id, role: user.role } };
}
Expand Down
Loading
Loading