From ef55d9bb24430610b60c1b77821307cd72c08c55 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Sat, 25 Jul 2026 18:23:29 -0300 Subject: feat: upload of the project Signed-off-by: zwlucas --- api/src/modules/auth/auth.controller.ts | 114 ++++++++++++++++++++++++++++++++ api/src/modules/auth/auth.repository.ts | 68 +++++++++++++++++++ api/src/modules/auth/auth.routes.ts | 55 +++++++++++++++ api/src/modules/auth/auth.schema.ts | 66 ++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 api/src/modules/auth/auth.controller.ts create mode 100644 api/src/modules/auth/auth.repository.ts create mode 100644 api/src/modules/auth/auth.routes.ts create mode 100644 api/src/modules/auth/auth.schema.ts (limited to 'api/src/modules/auth') diff --git a/api/src/modules/auth/auth.controller.ts b/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..8aec643 --- /dev/null +++ b/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,114 @@ +/** + * auth.controller.ts + * Lógica de negócio de autenticação. + */ + +import bcrypt from 'bcryptjs'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + findUserByEmail, + findUserById, + createUser, +} from './auth.repository.js'; +import type { RegisterBody, LoginBody } from './auth.schema.js'; + +const SALT_ROUNDS = 12; + +// ─── POST /auth/register ────────────────────────────────────────────────────── + +export async function registerHandler( + request: FastifyRequest<{ Body: RegisterBody }>, + reply: FastifyReply, +) { + const { name, email, password, role } = request.body; + const db = request.server.db; + + // Verifica duplicidade de e-mail + const existing = await findUserByEmail(db, email); + if (existing) { + return reply.conflict('Este e-mail já está cadastrado.'); + } + + // Apenas admins podem criar agentes ou outros admins + if ((role === 'agent' || role === 'admin')) { + const callerUser = request.user as { role?: string } | undefined; + if (!callerUser || callerUser.role !== 'admin') { + return reply.forbidden('Apenas administradores podem criar agentes ou admins.'); + } + } + + // Hash da senha (bcrypt com salt 12 ≈ ~250ms — seguro e aceitável) + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + const user = await createUser(db, { + name, + email, + password: hashedPassword, + role, + }); + + // Assina o JWT com o payload mínimo necessário + const token = await reply.jwtSign({ + id: user.id, + email: user.email, + role: user.role, + }); + + return reply.code(201).send({ token, user }); +} + +// ─── POST /auth/login ───────────────────────────────────────────────────────── + +export async function loginHandler( + request: FastifyRequest<{ Body: LoginBody }>, + reply: FastifyReply, +) { + const { email, password } = request.body; + const db = request.server.db; + + const user = await findUserByEmail(db, email); + + // Mensagem genérica: não revela se o e-mail existe ou não (segurança) + const INVALID_MSG = 'E-mail ou senha incorretos.'; + + if (!user) { + // Executa bcrypt mesmo sem usuário para evitar timing attacks + await bcrypt.compare(password, '$2b$12$invalidhashplaceholderthatnevermatchesanypassword'); + return reply.unauthorized(INVALID_MSG); + } + + if (!user.isActive) { + return reply.forbidden('Conta desativada. Entre em contato com o administrador.'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return reply.unauthorized(INVALID_MSG); + } + + const token = await reply.jwtSign({ + id: user.id, + email: user.email, + role: user.role, + }); + + // Retorna o user sem o campo password + const { password: _pwd, ...safeUser } = user; + return reply.send({ token, user: safeUser }); +} + +// ─── GET /auth/me ───────────────────────────────────────────────────────────── + +export async function meHandler( + request: FastifyRequest, + reply: FastifyReply, +) { + // request.user foi injetado pelo decorator authenticate + const user = await findUserById(request.server.db, request.user.id); + + if (!user) { + return reply.unauthorized('Usuário não encontrado.'); + } + + return reply.send(user); +} diff --git a/api/src/modules/auth/auth.repository.ts b/api/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..25a931b --- /dev/null +++ b/api/src/modules/auth/auth.repository.ts @@ -0,0 +1,68 @@ +/** + * auth.repository.ts + * Queries de banco exclusivas do módulo de autenticação. + */ + +import { eq } from 'drizzle-orm'; +import type { Database } from '@/db/connection.js'; +import { users, type NewUser } from '@/db/schema.js'; + +// ─── Busca com senha (apenas para verificação no login) ─────────────────────── + +export async function findUserByEmail(db: Database, email: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return user ?? null; +} + +// ─── Busca segura (sem password) ────────────────────────────────────────────── + +export async function findUserById(db: Database, id: number) { + const [user] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + + return user ?? null; +} + +// ─── Criação de usuário ─────────────────────────────────────────────────────── + +export async function createUser( + db: Database, + data: Pick, +) { + const now = new Date().toISOString(); + + const [user] = await db + .insert(users) + .values({ + ...data, + isActive: true, + createdAt: now, + updatedAt: now, + }) + .returning({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + isActive: users.isActive, + createdAt: users.createdAt, + }); + + return user!; +} diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..1de0e16 --- /dev/null +++ b/api/src/modules/auth/auth.routes.ts @@ -0,0 +1,55 @@ +/** + * auth.routes.ts + * + * Rotas públicas e protegidas de autenticação: + * POST /api/v1/auth/register → cria conta + * POST /api/v1/auth/login → retorna JWT + * GET /api/v1/auth/me → dados do usuário logado (protegida) + */ + +import type { FastifyInstance } from 'fastify'; +import { + registerBodySchema, + loginBodySchema, +} from './auth.schema.js'; +import { + registerHandler, + loginHandler, + meHandler, +} from './auth.controller.js'; + +async function authRoutes(fastify: FastifyInstance): Promise { + + // ── POST /auth/register ──────────────────────────────────────────────────── + fastify.post('/register', { + schema: { + tags: ['Auth'], + summary: 'Registra um novo usuário', + body: registerBodySchema, + }, + handler: registerHandler, + }); + + // ── POST /auth/login ─────────────────────────────────────────────────────── + fastify.post('/login', { + schema: { + tags: ['Auth'], + summary: 'Autentica e retorna um JWT', + body: loginBodySchema, + }, + handler: loginHandler, + }); + + // ── GET /auth/me ─────────────────────────────────────────────────────────── + fastify.get('/me', { + schema: { + tags: ['Auth'], + summary: 'Retorna o usuário autenticado', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + handler: meHandler, + }); +} + +export default authRoutes; diff --git a/api/src/modules/auth/auth.schema.ts b/api/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..7c9e8cd --- /dev/null +++ b/api/src/modules/auth/auth.schema.ts @@ -0,0 +1,66 @@ +/** + * auth.schema.ts + * Schemas Zod para registro, login e resposta de usuário. + */ + +import { z } from 'zod'; +import { USER_ROLES } from '@/db/schema.js'; + +// ─── Registro ───────────────────────────────────────────────────────────────── + +export const registerBodySchema = z.object({ + name: z + .string({ required_error: 'Nome é obrigatório' }) + .min(2, 'Nome deve ter no mínimo 2 caracteres') + .max(100) + .trim(), + + email: z + .string({ required_error: 'E-mail é obrigatório' }) + .email('E-mail inválido') + .toLowerCase() + .trim(), + + password: z + .string({ required_error: 'Senha é obrigatória' }) + .min(8, 'Senha deve ter no mínimo 8 caracteres') + .max(100), + + // Somente admins podem criar agentes/admins — validado no controller + role: z.enum(USER_ROLES).default('requester'), +}); + +export type RegisterBody = z.infer; + +// ─── Login ──────────────────────────────────────────────────────────────────── + +export const loginBodySchema = z.object({ + email: z + .string({ required_error: 'E-mail é obrigatório' }) + .email('E-mail inválido') + .toLowerCase() + .trim(), + + password: z.string({ required_error: 'Senha é obrigatória' }), +}); + +export type LoginBody = z.infer; + +// ─── Shape de resposta (NUNCA inclui password) ──────────────────────────────── + +export const userResponseSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + role: z.enum(USER_ROLES), + isActive: z.boolean(), + createdAt: z.string(), +}); + +export const authResponseSchema = z.object({ + token: z.string(), + user: userResponseSchema, +}); + +export type UserResponse = z.infer; +export type AuthResponse = z.infer; -- cgit v1.2.3