diff --git a/src/auth/config.ts b/src/auth/config.ts index aefbb6e..4278398 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -1,10 +1,6 @@ import type { ExpressAuthConfig } from "@auth/express"; import Credentials from "@auth/express/providers/credentials"; -import bcrypt from "bcryptjs"; -import type { RowDataPacket } from "mysql2"; -import pool from "../db/pool.js"; -import type { Utente } from "../types/index.js"; -import { validateLoginInput } from "../validation/utente.js"; +import { authenticateUser } from "./credentials.js"; const isProduction = process.env.NODE_ENV === "production"; @@ -36,28 +32,18 @@ export const authConfig: ExpressAuthConfig = { password: { label: "Password", type: "password" }, }, async authorize(credentials) { - const validation = validateLoginInput(credentials ?? {}); + const email = credentials?.email?.toString().trim().toLowerCase() ?? ""; + const password = credentials?.password?.toString() ?? ""; + if (!email || !password) return null; - if (!validation.success) return null; - - const { email, password } = validation.data; - - const [rows] = await pool.execute( - "SELECT UtenteID, Nome, Cognome, Email, PasswordHash, Ruolo FROM utente WHERE Email = ?", - [email] - ); - - const utente = rows[0] as Utente | undefined; - if (!utente) return null; - - const valid = await bcrypt.compare(password, utente.PasswordHash); - if (!valid) return null; + const user = await authenticateUser(email, password); + if (!user) return null; return { - id: String(utente.UtenteID), - email: utente.Email, - name: `${utente.Nome} ${utente.Cognome}`, - ruolo: utente.Ruolo, + id: String(user.id), + email: user.email, + name: `${user.nome} ${user.cognome}`, + ruolo: user.ruolo, }; }, }), diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts new file mode 100644 index 0000000..68a1a97 --- /dev/null +++ b/src/auth/credentials.ts @@ -0,0 +1,45 @@ +import bcrypt from "bcryptjs"; +import type { RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import type { Ruolo } from "../types/index.js"; + +interface UtenteAuthRow extends RowDataPacket { + UtenteID: number; + Nome: string; + Cognome: string; + Email: string; + PasswordHash: string; + Ruolo: Ruolo; +} + +export interface AuthenticatedUser { + id: number; + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; +} + +export async function authenticateUser( + email: string, + password: string +): Promise { + const [rows] = await pool.execute( + "SELECT UtenteID, Nome, Cognome, Email, PasswordHash, Ruolo FROM utente WHERE Email = ?", + [email] + ); + + const utente = rows[0]; + if (!utente) return null; + + const valid = await bcrypt.compare(password, utente.PasswordHash); + if (!valid) return null; + + return { + id: utente.UtenteID, + nome: utente.Nome, + cognome: utente.Cognome, + email: utente.Email, + ruolo: utente.Ruolo, + }; +} diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..50ff719 --- /dev/null +++ b/src/auth/session.ts @@ -0,0 +1,37 @@ +import { encode } from "@auth/core/jwt"; +import type { Response } from "express"; +import type { AuthenticatedUser } from "./credentials.js"; + +function getSessionCookieName(): string { + return process.env.NODE_ENV === "production" + ? "__Secure-authjs.session-token" + : "authjs.session-token"; +} + +export async function setAuthSession( + res: Response, + user: AuthenticatedUser +): Promise { + const cookieName = getSessionCookieName(); + const isProduction = process.env.NODE_ENV === "production"; + + const token = await encode({ + token: { + name: `${user.nome} ${user.cognome}`, + email: user.email, + sub: String(user.id), + id: String(user.id), + ruolo: user.ruolo, + }, + secret: process.env.AUTH_SECRET!, + salt: cookieName, + maxAge: 30 * 24 * 60 * 60, + }); + + res.cookie(cookieName, token, { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? "none" : "lax", + path: "/", + }); +} diff --git a/src/config/openapi.ts b/src/config/openapi.ts index 144a8ba..b8a91de 100644 --- a/src/config/openapi.ts +++ b/src/config/openapi.ts @@ -343,6 +343,63 @@ export const openApiSpec = { }, }, }, + "/api/utenti/login": { + post: { + tags: ["Utenti"], + summary: "Login utente", + description: + "Autentica l'utente con email e password. Imposta il cookie di sessione.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Login riuscito", + content: { "application/json": { schema: utenteSchema } }, + }, + 400: responses.validationError, + 401: { description: "Credenziali non valide" }, + }, + }, + }, + "/api/utenti/register": { + post: { + tags: ["Utenti"], + summary: "Registra utente", + description: + "Crea un nuovo utente (Dipendente o Referente Academy). Richiede autenticazione Referente Academy.", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UtenteCreate" }, + }, + }, + }, + responses: { + 201: { + description: "Utente registrato", + content: { "application/json": { schema: utenteSchema } }, + }, + 400: responses.validationError, + 403: responses.forbidden, + 409: { description: "Email giĆ  registrata" }, + }, + }, + }, "/api/utenti/{id}": { get: { tags: ["Utenti"], diff --git a/src/routes/utenti.ts b/src/routes/utenti.ts index d52eacf..63b2384 100644 --- a/src/routes/utenti.ts +++ b/src/routes/utenti.ts @@ -7,6 +7,8 @@ import bcrypt from "bcryptjs"; import { Router } from "express"; import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { authenticateUser } from "../auth/credentials.js"; +import { setAuthSession } from "../auth/session.js"; import pool from "../db/pool.js"; import { mapDbError } from "../errors/db.js"; import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js"; @@ -14,8 +16,10 @@ import type { Ruolo } from "../types/index.js"; import { UTENTE_CONSTRAINTS } from "../validation/constraints.js"; import { validateBody } from "../validation/middleware.js"; import { + validateLoginInput, validateUtenteCreate, validateUtenteUpdate, + type LoginInput, type UtenteCreateInput, type UtenteUpdateInput, } from "../validation/utente.js"; @@ -48,6 +52,127 @@ async function findUtenteById(id: number): Promise { return rows[0]; } +async function insertUtente(body: UtenteCreateInput): Promise { + const passwordHash = await bcrypt.hash(body.password, 10); + const [result] = await pool.execute( + "INSERT INTO utente (Nome, Cognome, Email, PasswordHash, Ruolo) VALUES (?, ?, ?, ?, ?)", + [body.nome, body.cognome, body.email, passwordHash, body.ruolo] + ); + + const created = await findUtenteById(result.insertId); + if (!created) { + throw new Error("Errore durante la creazione dell'utente"); + } + + return created; +} + +async function applyUtenteUpdate( + id: number, + updates: UtenteUpdateInput +): Promise { + const setClauses: string[] = []; + const values: (string | number)[] = []; + + if (updates.nome !== undefined) { + setClauses.push("Nome = ?"); + values.push(updates.nome); + } + if (updates.cognome !== undefined) { + setClauses.push("Cognome = ?"); + values.push(updates.cognome); + } + if (updates.email !== undefined) { + setClauses.push("Email = ?"); + values.push(updates.email); + } + if (updates.ruolo !== undefined) { + setClauses.push("Ruolo = ?"); + values.push(updates.ruolo); + } + if (updates.password) { + const passwordHash = await bcrypt.hash(updates.password, 10); + setClauses.push("PasswordHash = ?"); + values.push(passwordHash); + } + + await pool.execute( + `UPDATE utente SET ${setClauses.join(", ")} WHERE UtenteID = ?`, + [...values, id] + ); + + const updated = await findUtenteById(id); + if (!updated) { + throw new Error("Errore durante l'aggiornamento dell'utente"); + } + + return updated; +} + +function handleUtenteDbError( + res: import("express").Response, + error: unknown +): boolean { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return true; + } + return false; +} + +async function createUtenteHandler( + body: UtenteCreateInput, + res: import("express").Response +): Promise { + try { + const created = await insertUtente(body); + res.status(201).json(toUtenteResponse(created)); + } catch (error) { + if (handleUtenteDbError(res, error)) return; + if (error instanceof Error && error.message.includes("creazione")) { + res.status(500).json({ error: error.message }); + return; + } + throw error; + } +} + +router.post("/login", validateBody(validateLoginInput), async (_req, res) => { + const { email, password } = res.locals.validatedBody as LoginInput; + + const user = await authenticateUser(email, password); + if (!user) { + res.status(401).json({ error: "Credenziali non valide" }); + return; + } + + await setAuthSession(res, user); + res.json({ + id: user.id, + nome: user.nome, + cognome: user.cognome, + email: user.email, + ruolo: user.ruolo, + }); +}); + +router.post( + "/register", + requireAuth, + requireReferenteAcademy, + validateBody(validateUtenteCreate), + async (_req, res) => { + await createUtenteHandler( + res.locals.validatedBody as UtenteCreateInput, + res + ); + } +); + router.use(requireAuth, requireReferenteAcademy); router.get("/", async (req, res) => { @@ -90,33 +215,10 @@ router.get("/:id", async (req, res) => { }); router.post("/", validateBody(validateUtenteCreate), async (_req, res) => { - const body = res.locals.validatedBody as UtenteCreateInput; - - try { - const passwordHash = await bcrypt.hash(body.password, 10); - const [result] = await pool.execute( - "INSERT INTO utente (Nome, Cognome, Email, PasswordHash, Ruolo) VALUES (?, ?, ?, ?, ?)", - [body.nome, body.cognome, body.email, passwordHash, body.ruolo] - ); - - const created = await findUtenteById(result.insertId); - if (!created) { - res.status(500).json({ error: "Errore durante la creazione dell'utente" }); - return; - } - - res.status(201).json(toUtenteResponse(created)); - } catch (error) { - const mapped = mapDbError(error); - if (mapped) { - res.status(mapped.status).json({ - error: mapped.error, - fields: mapped.fields, - }); - return; - } - throw error; - } + await createUtenteHandler( + res.locals.validatedBody as UtenteCreateInput, + res + ); }); router.put("/:id", validateBody(validateUtenteUpdate), async (req, res) => { @@ -133,51 +235,14 @@ router.put("/:id", validateBody(validateUtenteUpdate), async (req, res) => { } const updates = res.locals.validatedBody as UtenteUpdateInput; - const setClauses: string[] = []; - const values: (string | number)[] = []; - - if (updates.nome !== undefined) { - setClauses.push("Nome = ?"); - values.push(updates.nome); - } - if (updates.cognome !== undefined) { - setClauses.push("Cognome = ?"); - values.push(updates.cognome); - } - if (updates.email !== undefined) { - setClauses.push("Email = ?"); - values.push(updates.email); - } - if (updates.ruolo !== undefined) { - setClauses.push("Ruolo = ?"); - values.push(updates.ruolo); - } - if (updates.password) { - const passwordHash = await bcrypt.hash(updates.password, 10); - setClauses.push("PasswordHash = ?"); - values.push(passwordHash); - } try { - await pool.execute( - `UPDATE utente SET ${setClauses.join(", ")} WHERE UtenteID = ?`, - [...values, id] - ); - - const updated = await findUtenteById(id); - if (!updated) { - res.status(500).json({ error: "Errore durante l'aggiornamento dell'utente" }); - return; - } - + const updated = await applyUtenteUpdate(id, updates); res.json(toUtenteResponse(updated)); } catch (error) { - const mapped = mapDbError(error); - if (mapped) { - res.status(mapped.status).json({ - error: mapped.error, - fields: mapped.fields, - }); + if (handleUtenteDbError(res, error)) return; + if (error instanceof Error && error.message.includes("aggiornamento")) { + res.status(500).json({ error: error.message }); return; } throw error; diff --git a/src/validation/utente.ts b/src/validation/utente.ts index 4779dc0..02a9230 100644 --- a/src/validation/utente.ts +++ b/src/validation/utente.ts @@ -62,8 +62,8 @@ export function validateLoginInput( return buildResult(data, errors); } -function validateUtenteProfileFields( - data: UtenteCreateInput, +function validateNomeCognomeEmail( + data: Pick, errors: FieldErrors ): void { validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); @@ -91,23 +91,39 @@ function validateUtenteProfileFields( "Email" ); validateEmailFormat(errors, "email", data.email); +} - validateRequired(errors, "password", data.password, "Password obbligatoria"); +function validatePasswordField( + errors: FieldErrors, + password: string, + options: { required: boolean } +): void { + if (options.required) { + validateRequired(errors, "password", password, "Password obbligatoria"); + } + if (!password) return; validateMinLength( errors, "password", - data.password, + password, UTENTE_CONSTRAINTS.password.minLength, "Password" ); validateMaxLength( errors, "password", - data.password, + password, UTENTE_CONSTRAINTS.password.maxLength, "Password" ); +} +function validateUtenteProfileFields( + data: UtenteCreateInput, + errors: FieldErrors +): void { + validateNomeCognomeEmail(data, errors); + validatePasswordField(errors, data.password, { required: true }); validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); validateEnum( errors, @@ -135,7 +151,6 @@ export function validateUtenteCreate( return buildResult(data, errors); } - export function validateUtenteUpdate( raw: Record ): ValidationResult { @@ -183,20 +198,7 @@ export function validateUtenteUpdate( const password = raw.password?.toString() ?? ""; if (password) { data.password = password; - validateMinLength( - errors, - "password", - password, - UTENTE_CONSTRAINTS.password.minLength, - "Password" - ); - validateMaxLength( - errors, - "password", - password, - UTENTE_CONSTRAINTS.password.maxLength, - "Password" - ); + validatePasswordField(errors, password, { required: false }); } }