diff --git a/initdb/02_seed.sql b/initdb/02_seed.sql index f34422f..3203f7e 100644 --- a/initdb/02_seed.sql +++ b/initdb/02_seed.sql @@ -24,13 +24,12 @@ INSERT INTO utente (Nome, Cognome, Email, PasswordHash, Ruolo) VALUES -- ------------------------------------------------------------ -- Clienti -- ------------------------------------------------------------ -INSERT INTO cliente (Nome, Cognome, Comune, Provincia, Telefono, Email, Note) VALUES - ('Mario', 'Rossi', 'Milano', 'MI', '3331112233', 'mario.rossi@email.it', 'Consegne preferibilmente al mattino'), - ('Laura', 'Verdi', 'Monza', 'MB', '3334445566', 'laura.verdi@email.it', NULL), - ('Marco', 'Neri', 'Bergamo', 'BG', '3337778899', 'marco.neri@email.it', 'Citofono non funzionante, chiamare al telefono'), - ('Giulia', 'Ferrari', 'Brescia', 'BS', '3332223344', 'giulia.ferrari@email.it', 'Ritiro abituale presso ufficio'), - ('Paolo', 'Romano', 'Como', 'CO', '3339988776', 'paolo.romano@email.it', 'Richiede firma del destinatario'); - +INSERT INTO cliente (Nome, Cognome, Via, Comune, Provincia, Telefono, Email, Note) VALUES + ('Mario', 'Rossi', 'Via Roma 12', 'Milano', 'MI', '3331112233', 'mario.rossi@email.it', 'Consegne preferibilmente al mattino'), + ('Laura', 'Verdi', 'Corso Italia 45', 'Monza', 'MB', '3334445566', 'laura.verdi@email.it', NULL), + ('Marco', 'Neri', 'Via Garibaldi 8', 'Bergamo', 'BG', '3337778899', 'marco.neri@email.it', 'Citofono non funzionante, chiamare al telefono'), + ('Giulia', 'Ferrari', 'Viale Venezia 101', 'Brescia', 'BS', '3332223344', 'giulia.ferrari@email.it', 'Ritiro abituale presso ufficio'), + ('Paolo', 'Romano', 'Via Manzoni 3', 'Como', 'CO', '3339988776', 'paolo.romano@email.it', 'Richiede firma del destinatario'); -- ------------------------------------------------------------ -- Consegne -- ClienteID: 1=Mario, 2=Laura, 3=Marco, 4=Giulia, 5=Paolo diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..aefbb6e --- /dev/null +++ b/src/auth/config.ts @@ -0,0 +1,81 @@ +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"; + +const isProduction = process.env.NODE_ENV === "production"; + +const crossOriginCookieOptions = { + httpOnly: true, + sameSite: "none" as const, + secure: true, + path: "/", +}; + +export const authConfig: ExpressAuthConfig = { + basePath: "/api/auth", + trustHost: true, + secret: process.env.AUTH_SECRET, + useSecureCookies: isProduction, + session: { strategy: "jwt" }, + pages: { signIn: "/api/auth/error" }, + cookies: isProduction + ? { + sessionToken: { options: crossOriginCookieOptions }, + csrfToken: { options: { ...crossOriginCookieOptions, httpOnly: false } }, + callbackUrl: { options: crossOriginCookieOptions }, + } + : undefined, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const validation = validateLoginInput(credentials ?? {}); + + 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; + + return { + id: String(utente.UtenteID), + email: utente.Email, + name: `${utente.Nome} ${utente.Cognome}`, + ruolo: utente.Ruolo, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.ruolo = user.ruolo; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id as string; + session.user.ruolo = token.ruolo!; + } + return session; + }, + }, +}; diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..384b8cc --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,13 @@ +import mysql from "mysql2/promise"; + +const pool = mysql.createPool({ + host: process.env.DB_HOST ?? "127.0.0.1", + port: Number(process.env.DB_PORT ?? 3307), + database: process.env.DB_NAME ?? "prenotazioni", + user: process.env.DB_USER ?? "prenotazioni_user", + password: process.env.DB_PASSWORD ?? "", + waitForConnections: true, + connectionLimit: 10, +}); + +export default pool; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..3cd1bb0 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,39 @@ +import { getSession } from "@auth/express"; +import type { NextFunction, Request, Response } from "express"; +import { authConfig } from "../auth/config.js"; + +export async function requireAuth(req: Request, res: Response, next: NextFunction) { + const session = await getSession(req, authConfig); + if (!session?.user?.id) { + res.status(401).json({ error: "Autenticazione richiesta" }); + return; + } + req.user = { + id: Number(session.user.id), + email: session.user.email, + name: session.user.name, + ruolo: session.user.ruolo, + }; + next(); +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction) { + if (!req.user?.ruolo) { + res.status(403).json({ error: "Operazione riservata agli amministratori" }); + return; + } + next(); +} + +declare global { + namespace Express { + interface Request { + user?: { + id: number; + email: string; + name: string; + ruolo: string; + }; + } + } +} diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts new file mode 100644 index 0000000..267c8a4 --- /dev/null +++ b/src/types/auth.d.ts @@ -0,0 +1,22 @@ +import type { Ruolo } from "./index.js"; + +declare module "@auth/core/types" { + interface User { + ruolo?: Ruolo; + } + interface Session { + user: { + id: string; + email: string; + name: string; + ruolo: Ruolo; + }; + } +} + +declare module "@auth/core/jwt" { + interface JWT { + id?: string; + ruolo?: Ruolo; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index cb5febd..5eab934 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,8 +16,8 @@ export interface Utente { export interface Cliente { ClienteID: number; Nome: string; - Via: string; Cognome: string; + Via: string; Comune: string; Provincia: string; Telefono: string; diff --git a/src/validation/primitives.ts b/src/validation/primitives.ts new file mode 100644 index 0000000..8883590 --- /dev/null +++ b/src/validation/primitives.ts @@ -0,0 +1,198 @@ +//========================================== +// File: primitives.ts +// Funzioni di validazione dei dati prmitivi +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//========================================== + +export type FieldErrors = Record; + +export type ValidationResult = + | { success: true; data: T } + | { success: false; errors: FieldErrors }; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeString(value: unknown): string { + return value?.toString().trim() ?? ""; +} + +export function normalizeEmail(value: unknown): string { + return normalizeString(value).toLowerCase(); +} + +export function normalizeBoolean(value: unknown, defaultValue = false): boolean { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + return defaultValue; +} + +export function normalizeDecimal(value: unknown): number { + if (typeof value === "number") return value; + const normalized = value?.toString().trim().replace(",", ".") ?? ""; + if (!normalized) return Number.NaN; + return Number(normalized); +} + +export function normalizePositiveInt(value: unknown): number { + if (typeof value === "number") return value; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : Number.NaN; +} + +export function normalizeTime(value: unknown): string { + const str = normalizeString(value); + if (!str) return ""; + if (/^\d{2}:\d{2}$/.test(str)) return `${str}:00`; + return str; +} + +export function validateRequired( + errors: FieldErrors, + field: string, + value: string, + message: string +): void { + if (!value) errors[field] = message; +} + +export function validateMaxLength( + errors: FieldErrors, + field: string, + value: string, + maxLength: number, + label: string +): void { + if (value.length > maxLength) { + errors[field] = `${label} deve contenere al massimo ${maxLength} caratteri`; + } +} + +export function validateMinLength( + errors: FieldErrors, + field: string, + value: string, + minLength: number, + label: string +): void { + if (value && value.length < minLength) { + errors[field] = `${label} deve contenere almeno ${minLength} caratteri`; + } +} + +export function validateEmailFormat( + errors: FieldErrors, + field: string, + value: string +): void { + if (value && !EMAIL_REGEX.test(value)) { + errors[field] = "Formato email non valido"; + } +} + +export function validateEnum( + errors: FieldErrors, + field: string, + value: string, + allowedValues: readonly string[], + label: string +): void { + if (value && !allowedValues.includes(value)) { + errors[field] = `${label} non valido`; + } +} + +export function validateBoolean( + errors: FieldErrors, + field: string, + value: unknown, + label: string +): void { + if (value === undefined || value === null || value === "") return; + if ( + value !== true && + value !== false && + value !== "true" && + value !== "false" + ) { + errors[field] = `${label} non valido`; + } +} + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const TIME_REGEX = /^\d{2}:\d{2}:\d{2}$/; + +export function validateDate( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!DATE_REGEX.test(value)) { + errors[field] = `${label} non valida`; + return; + } + const parsed = new Date(`${value}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + errors[field] = `${label} non valida`; + } +} + +export function validateTimeFormat( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!TIME_REGEX.test(value)) { + errors[field] = `${label} non valida`; + } +} + +export function validatePositiveInt( + errors: FieldErrors, + field: string, + value: number, + label: string, + required = true +): void { + if (Number.isNaN(value) || value <= 0) { + errors[field] = required ? `${label} obbligatorio` : `${label} non valido`; + } +} + +export function validateTimeRange( + errors: FieldErrors, + startField: string, + endField: string, + start: string, + end: string +): void { + if (!start || !end) return; + if (start >= end) { + errors[endField] = + "L'ora di fine deve essere successiva all'ora di inizio"; + } +} + +export function validateDecimalPositive( + errors: FieldErrors, + field: string, + value: unknown, + label: string, + min = 0.01 +): void { + if (value === undefined || value === null || value === "") return; + const num = typeof value === "number" ? value : Number(value); + if (Number.isNaN(num) || num < min) { + errors[field] = `${label} deve essere almeno ${min}`; + } +} + +export function buildResult(data: T, errors: FieldErrors): ValidationResult { + return Object.keys(errors).length > 0 + ? { success: false, errors } + : { success: true, data }; +} diff --git a/src/validation/utente.ts b/src/validation/utente.ts new file mode 100644 index 0000000..16d13a8 --- /dev/null +++ b/src/validation/utente.ts @@ -0,0 +1,220 @@ +import type { Ruolo } from "../types/index.js"; +import { UTENTE_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizeEmail, + normalizeString, + validateEmailFormat, + validateEnum, + validateMaxLength, + validateMinLength, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface LoginInput { + email: string; + password: string; +} + +export interface UtenteCreateInput { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo; +} + +export type UtenteUpdateInput = Partial; + +export function normalizeLoginInput(raw: Record): LoginInput { + return { + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + }; +} + +export function validateLoginInput( + raw: Record +): ValidationResult { + const data = normalizeLoginInput(raw); + const errors: FieldErrors = {}; + + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + validateRequired(errors, "password", data.password, "Password obbligatoria"); + validateMaxLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); + + return buildResult(data, errors); +} + +function validateUtenteProfileFields( + data: UtenteCreateInput, + errors: FieldErrors +): void { + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + UTENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + UTENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + + validateRequired(errors, "password", data.password, "Password obbligatoria"); + validateMinLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); + + validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); +} + +export function validateUtenteCreate( + raw: Record +): ValidationResult { + const data: UtenteCreateInput = { + nome: normalizeString(raw.nome), + cognome: normalizeString(raw.cognome), + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + ruolo: normalizeString(raw.ruolo) as Ruolo, + }; + const errors: FieldErrors = {}; + + validateUtenteProfileFields(data, errors); + + return buildResult(data, errors); +} + + +export function validateUTENTEUpdate( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: UtenteUpdateInput = {}; + + if ("nome" in raw) { + data.nome = normalizeString(raw.nome); + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + UTENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + } + + if ("cognome" in raw) { + data.cognome = normalizeString(raw.cognome); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + UTENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + } + + if ("email" in raw) { + data.email = normalizeEmail(raw.email); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + } + + if ("password" in raw) { + 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" + ); + } + } + + if ("ruolo" in raw) { + data.ruolo = normalizeString(raw.ruolo) as Ruolo; + validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } + + return buildResult(data, errors); +} \ No newline at end of file