diff --git a/README.md b/README.md index 94505e7..40807fe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Application de gestion des présences par demi-journée, multi-sociétés, avec ## Démarrage -Toute la configuration (ports, mot de passe base de données, `JWT_SECRET`, identifiants admin) est définie directement dans `docker-compose.yml` — il n'y a pas de fichier `.env` à créer. Éditez les valeurs dans `docker-compose.yml` avant le premier démarrage (au minimum `POSTGRES_PASSWORD`, `JWT_SECRET` et `ADMIN_PASSWORD`), puis : +Toute la configuration (ports, mot de passe base de données, `JWT_SECRET`, identifiants admin) est définie directement dans `docker-compose.yml` — il n'y a pas de fichier `.env` à créer. Éditez les valeurs dans `docker-compose.yml` avant le premier démarrage (au minimum `POSTGRES_PASSWORD` et `ADMIN_PASSWORD`), puis : ```bash docker compose up -d --build @@ -27,15 +27,24 @@ docker compose up -d --build Ports par défaut (modifiables directement dans `docker-compose.yml`) : -| Service | Port hôte | -|-----------|-----------| -| Frontend | 8781 | -| API | 4790 | -| Postgres | 6543 | +| Service | Port hôte | Accessible depuis | +|-----------|-----------|--------------------------| +| Frontend | 8781 | le réseau | +| API | 4790 | la machine hôte seulement | +| Postgres | 6543 | la machine hôte seulement | + +Le navigateur passe toujours par le frontend (qui proxifie `/api`) ; l'API et +Postgres ne sont publiés que sur `127.0.0.1`, pour l'administration locale +(psql, sauvegardes). + +**`JWT_SECRET`** : laissez-le vide pour que l'API génère un secret aléatoire, +conservé en base (les sessions survivent aux redémarrages). Si vous le +renseignez, il doit faire au moins 32 caractères aléatoires ; une valeur +d'exemple ou trop courte est ignorée. Ouvrir http://localhost:8781 -Un compte administrateur est créé automatiquement au premier démarrage avec les identifiants définis par `ADMIN_EMAIL` / `ADMIN_PASSWORD` dans `docker-compose.yml` (par défaut `admin@presencia.local` / `ChangeMe123!`). **Changez ce mot de passe après la première connexion** (aucune page de changement de mot de passe en libre-service n'est fournie côté cadre ; un administrateur peut réinitialiser le mot de passe de n'importe quel compte depuis l'onglet Utilisateurs). +Un compte administrateur est créé automatiquement au premier démarrage avec les identifiants définis par `ADMIN_EMAIL` / `ADMIN_PASSWORD` dans `docker-compose.yml` (par défaut `admin@presencia.local` / `ChangeMe123!`). **Changez ce mot de passe après la première connexion** via « Mon mot de passe » (en bas de la barre latérale, ou l'icône cadenas sur mobile). Chaque utilisateur peut changer le sien ; un administrateur peut aussi réinitialiser celui de n'importe quel compte depuis l'onglet Utilisateurs. Si vous changez les identifiants admin dans `docker-compose.yml` *après* un premier démarrage, ils n'auront aucun effet : le compte admin n'est créé qu'une seule fois (au premier démarrage, base vide). Pour le modifier ensuite, utilisez l'écran Utilisateurs une fois connecté, ou réinitialisez le volume `presencia_pgdata`. @@ -56,6 +65,15 @@ Si vous changez les identifiants admin dans `docker-compose.yml` *après* un pre - En fin de mois, cliquer sur **« Valider mon mois »** : les saisies sont alors verrouillées et transmises pour validation à l'administrateur. Si une correction est nécessaire après coup, il faut qu'un administrateur réouvre le mois. - **Historique** : retrouver les mois précédents et leurs totaux, et les rouvrir en lecture. +## Sécurité + +- Mots de passe hachés (bcrypt), 8 caractères minimum. +- Session par cookie `httpOnly` / `SameSite=Lax` (12 h). Passez `COOKIE_SECURE: "true"` si l'application est servie en HTTPS. +- Le compte est relu en base à chaque requête : désactiver un compte, changer son rôle ou réinitialiser son mot de passe prend effet immédiatement, sans attendre l'expiration de la session. +- Connexion limitée à 10 échecs par adresse e-mail sur 15 minutes. +- Un administrateur ne peut ni désactiver ni rétrograder son propre compte. +- En-têtes de sécurité (CSP, `X-Frame-Options`, `nosniff`…) posés par Nginx et par l'API. + ## Architecture technique ``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c81b8d3 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +.env diff --git a/backend/Dockerfile b/backend/Dockerfile index d984c81..fb29963 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,12 +2,17 @@ FROM node:20-alpine WORKDIR /app -COPY package.json ./ -RUN npm install --omit=dev +# Install from the lockfile so the image gets exactly the tested versions. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force COPY . . +ENV NODE_ENV=production ENV PORT=4790 EXPOSE 4790 +# Run as the unprivileged user shipped with the node image, not root. +USER node + CMD ["node", "src/index.js"] diff --git a/backend/migrations/002_security.sql b/backend/migrations/002_security.sql new file mode 100644 index 0000000..c3d5c0e --- /dev/null +++ b/backend/migrations/002_security.sql @@ -0,0 +1,10 @@ +-- Idempotent: run on every start, after 001_init.sql. + +-- Server-side settings, e.g. the generated JWT signing secret. +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Sessions issued before this instant are rejected (password change). +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ; diff --git a/backend/src/bootstrap.js b/backend/src/bootstrap.js index 32a57b3..92205f7 100644 --- a/backend/src/bootstrap.js +++ b/backend/src/bootstrap.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const bcrypt = require('bcryptjs'); const db = require('./db'); +const { initJwtSecret } = require('./middleware/auth'); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -32,13 +33,17 @@ async function runMigrations() { ); if (rows[0].exists) { console.log('Schéma déjà initialisé.'); - return; + } else { + console.log('Initialisation du schéma de base de données...'); + await db.query(readMigration('001_init.sql')); + console.log('Schéma créé.'); } - const sqlPath = path.join(__dirname, '..', 'migrations', '001_init.sql'); - const sql = fs.readFileSync(sqlPath, 'utf8'); - console.log('Initialisation du schéma de base de données...'); - await db.query(sql); - console.log('Schéma créé.'); + // Idempotent, so safe on every start: brings existing databases up to date. + await db.query(readMigration('002_security.sql')); +} + +function readMigration(name) { + return fs.readFileSync(path.join(__dirname, '..', 'migrations', name), 'utf8'); } async function seedAdmin() { @@ -60,7 +65,7 @@ async function seedAdmin() { console.log('========================================================'); console.log(' Compte administrateur créé :'); console.log(` Email : ${email}`); - console.log(` Mot de passe : ${password}`); + console.log(' Mot de passe : celui de ADMIN_PASSWORD'); console.log(' Merci de le changer après la première connexion.'); console.log('========================================================'); } @@ -68,6 +73,7 @@ async function seedAdmin() { async function bootstrap() { await waitForDb(); await runMigrations(); + await initJwtSecret(); await seedAdmin(); } diff --git a/backend/src/db.js b/backend/src/db.js index 434eb7d..08cc478 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,4 +1,9 @@ -const { Pool } = require('pg'); +const { Pool, types } = require('pg'); + +// Return DATE columns as 'YYYY-MM-DD' strings. By default pg builds a JS Date +// at local midnight, and toISOString() then shifts it to the previous day as +// soon as the server runs in a timezone ahead of UTC (e.g. TZ=Europe/Paris). +types.setTypeParser(types.builtins.DATE, (v) => v); const pool = new Pool({ host: process.env.PGHOST || 'db', diff --git a/backend/src/index.js b/backend/src/index.js index 905ed33..a82df35 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,7 +15,18 @@ const exportRoutes = require('./routes/export'); const app = express(); const PORT = process.env.PORT || 4790; -app.use(express.json()); +app.disable('x-powered-by'); +// The API is also reachable on its own port, without the nginx headers. +app.use((req, res, next) => { + res.set({ + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'same-origin', + 'Cache-Control': 'no-store', + }); + next(); +}); +app.use(express.json({ limit: '100kb' })); app.use(cookieParser()); if (process.env.CORS_ORIGIN) { app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true })); @@ -30,7 +41,21 @@ app.use('/api/attendance', attendanceRoutes); app.use('/api/validations', validationRoutes); app.use('/api/export', exportRoutes); +app.use('/api', (req, res) => res.status(404).json({ error: 'Route inconnue' })); + +// Postgres errors caused by the request content rather than by the server. +const CLIENT_PG_ERRORS = { + '22P02': 'Valeur invalide', // invalid_text_representation + '22007': 'Date invalide', // invalid_datetime_format + '22008': 'Date invalide', // datetime_field_overflow + '22001': 'Valeur trop longue', // string_data_right_truncation + '23503': 'Élément référencé introuvable', // foreign_key_violation +}; + app.use((err, req, res, next) => { + if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Corps de requête JSON invalide' }); + if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Requête trop volumineuse' }); + if (CLIENT_PG_ERRORS[err.code]) return res.status(400).json({ error: CLIENT_PG_ERRORS[err.code] }); console.error(err); res.status(500).json({ error: 'Erreur interne du serveur' }); }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7225991..7373d96 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,20 +1,40 @@ +const crypto = require('crypto'); const jwt = require('jsonwebtoken'); +const db = require('../db'); -const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; const COOKIE_NAME = 'presencia_token'; -function signToken(user) { - return jwt.sign( - { - id: user.id, - role: user.role, - companyId: user.company_id, - fullName: user.full_name, - email: user.email, - }, - JWT_SECRET, - { expiresIn: '12h' } +// Values that have shipped in this repository or its docs. Anyone can read +// them, so a token signed with one of them can be forged by anyone. +const KNOWN_PLACEHOLDERS = new Set([ + 'change-me-to-a-long-random-string', + 'dev-secret-change-me', +]); + +let jwtSecret = null; + +// Called once at startup. Uses JWT_SECRET when it is a real secret; otherwise +// generates one and keeps it in the database so sessions survive restarts. +async function initJwtSecret() { + const fromEnv = process.env.JWT_SECRET; + if (fromEnv && fromEnv.length >= 32 && !KNOWN_PLACEHOLDERS.has(fromEnv)) { + jwtSecret = fromEnv; + return; + } + if (fromEnv) { + console.warn('JWT_SECRET ignoré (trop court ou valeur d’exemple) : un secret aléatoire est utilisé à la place.'); + } + const candidate = crypto.randomBytes(48).toString('base64url'); + await db.query( + "INSERT INTO app_settings (key, value) VALUES ('jwt_secret', $1) ON CONFLICT (key) DO NOTHING", + [candidate] ); + const { rows } = await db.query("SELECT value FROM app_settings WHERE key = 'jwt_secret'"); + jwtSecret = rows[0].value; +} + +function signToken(user) { + return jwt.sign({ id: user.id }, jwtSecret, { expiresIn: '12h' }); } function setAuthCookie(res, token) { @@ -31,16 +51,39 @@ function clearAuthCookie(res) { res.clearCookie(COOKIE_NAME, { path: '/' }); } -function requireAuth(req, res, next) { +// The token only proves who the caller is. Role, company and whether the +// account is still active are read from the database on every request, so +// deactivating or demoting an account takes effect immediately rather than +// when its 12-hour token expires. +async function requireAuth(req, res, next) { const token = req.cookies && req.cookies[COOKIE_NAME]; if (!token) return res.status(401).json({ error: 'Non authentifié' }); + let payload; try { - const payload = jwt.verify(token, JWT_SECRET); - req.user = payload; - next(); + payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }); } catch (err) { + clearAuthCookie(res); return res.status(401).json({ error: 'Session invalide ou expirée' }); } + const { rows } = await db.query( + `SELECT id, full_name, email, role, company_id, active, password_changed_at + FROM users WHERE id = $1`, + [payload.id] + ); + const u = rows[0]; + const changedAt = u && u.password_changed_at ? Math.floor(u.password_changed_at.getTime() / 1000) : 0; + if (!u || !u.active || payload.iat < changedAt) { + clearAuthCookie(res); + return res.status(401).json({ error: 'Session invalide ou expirée' }); + } + req.user = { + id: u.id, + role: u.role, + companyId: u.company_id, + fullName: u.full_name, + email: u.email, + }; + next(); } function requireAdmin(req, res, next) { @@ -51,6 +94,7 @@ function requireAdmin(req, res, next) { } module.exports = { + initJwtSecret, signToken, setAuthCookie, clearAuthCookie, diff --git a/backend/src/routes/attendance.js b/backend/src/routes/attendance.js index 705970b..2e02792 100644 --- a/backend/src/routes/attendance.js +++ b/backend/src/routes/attendance.js @@ -1,6 +1,7 @@ const express = require('express'); const db = require('../db'); const { requireAuth } = require('../middleware/auth'); +const { isId, isDate, parseYearMonth, monthStart } = require('../utils/validate'); const router = express.Router(); router.use(requireAuth); @@ -8,103 +9,105 @@ router.use(requireAuth); const STATUSES = ['present', 'absent', 'conge', 'rtt']; const PERIODS = ['AM', 'PM']; -async function getTargetUser(req, requestedUserId) { - // Cadre can only ever act on themselves. Admin may act on any user. - if (req.user.role === 'admin' && requestedUserId) { - const { rows } = await db.query( - 'SELECT id, role, company_id FROM users WHERE id = $1', - [requestedUserId] - ); - return rows[0] || null; +// Resolves whose planning a request acts on. A cadre can only ever act on +// themselves; an admin may pass user_id to act on anyone. Sends the error +// response and returns null when the request is not allowed. +async function resolveTarget(req, res, requestedUserId) { + if (requestedUserId === undefined || requestedUserId === null || requestedUserId === '') { + return { id: req.user.id, company_id: req.user.companyId }; } - return { id: req.user.id, role: req.user.role, company_id: req.user.companyId }; + if (req.user.role !== 'admin') { + res.status(403).json({ error: 'Accès refusé' }); + return null; + } + if (!isId(String(requestedUserId))) { + res.status(400).json({ error: 'Utilisateur invalide' }); + return null; + } + const { rows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [requestedUserId]); + if (!rows[0]) { + res.status(404).json({ error: 'Utilisateur introuvable' }); + return null; + } + return rows[0]; } -async function getMonthLock(userId, year, month) { +async function monthState(target, { year, month }) { const { rows } = await db.query( - 'SELECT cadre_validated, cadre_validated_at FROM month_locks WHERE user_id = $1 AND year = $2 AND month = $3', - [userId, year, month] + `SELECT + (SELECT row_to_json(ml) FROM ( + SELECT cadre_validated, cadre_validated_at FROM month_locks + WHERE user_id = $1 AND year = $3 AND month = $4) ml) AS lock, + (SELECT row_to_json(cv) FROM ( + SELECT admin_validated, admin_validated_at FROM company_month_validations + WHERE company_id = $2 AND year = $3 AND month = $4) cv) AS company`, + [target.id, target.company_id, year, month] ); - return rows[0] || { cadre_validated: false, cadre_validated_at: null }; + const lock = rows[0].lock || {}; + const company = rows[0].company || {}; + return { + cadreValidated: !!lock.cadre_validated, + cadreValidatedAt: lock.cadre_validated_at || null, + companyValidated: !!company.admin_validated, + companyValidatedAt: company.admin_validated_at || null, + }; } -async function getCompanyValidation(companyId, year, month) { - if (!companyId) return { admin_validated: false, admin_validated_at: null }; - const { rows } = await db.query( - 'SELECT admin_validated, admin_validated_at FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3', - [companyId, year, month] - ); - return rows[0] || { admin_validated: false, admin_validated_at: null }; +// Same lock rules for every write: nobody edits a month the admin validated +// for the company; a cadre cannot edit a month they validated themselves. +// Sends a 423 and returns false when the month is locked. +async function ensureWritable(req, res, target, ym) { + const st = await monthState(target, ym); + if (st.companyValidated) { + res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' }); + return false; + } + if (req.user.role !== 'admin' && st.cadreValidated) { + res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' }); + return false; + } + return true; } +const ymOf = (date) => ({ year: Number(date.slice(0, 4)), month: Number(date.slice(5, 7)) }); + router.get('/', async (req, res) => { - const year = parseInt(req.query.year, 10); - const month = parseInt(req.query.month, 10); - if (!year || !month) return res.status(400).json({ error: 'year et month requis' }); + const ym = parseYearMonth(req.query.year, req.query.month); + if (!ym) return res.status(400).json({ error: 'year et month requis' }); + const target = await resolveTarget(req, res, req.query.user_id); + if (!target) return; - const requestedUserId = req.query.user_id; - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); - - const start = `${year}-${String(month).padStart(2, '0')}-01`; - const { rows } = await db.query( - `SELECT entry_date, period, status - FROM attendance_entries - WHERE user_id = $1 - AND entry_date >= $2::date - AND entry_date < ($2::date + INTERVAL '1 month') - ORDER BY entry_date, period`, - [target.id, start] - ); - - const lock = await getMonthLock(target.id, year, month); - const companyValidation = await getCompanyValidation(target.company_id, year, month); + const [{ rows }, st] = await Promise.all([ + db.query( + `SELECT entry_date, period, status + FROM attendance_entries + WHERE user_id = $1 + AND entry_date >= $2::date + AND entry_date < ($2::date + INTERVAL '1 month') + ORDER BY entry_date, period`, + [target.id, monthStart(ym)] + ), + monthState(target, ym), + ]); res.json({ userId: target.id, - entries: rows.map((r) => ({ - date: r.entry_date.toISOString().slice(0, 10), - period: r.period, - status: r.status, - })), - cadreValidated: lock.cadre_validated, - cadreValidatedAt: lock.cadre_validated_at, - companyValidated: companyValidation.admin_validated, - companyValidatedAt: companyValidation.admin_validated_at, - editable: - req.user.role === 'admin' - ? !companyValidation.admin_validated - : !lock.cadre_validated && !companyValidation.admin_validated, + entries: rows.map((r) => ({ date: r.entry_date, period: r.period, status: r.status })), + ...st, + editable: req.user.role === 'admin' + ? !st.companyValidated + : !st.cadreValidated && !st.companyValidated, }); }); router.put('/', async (req, res) => { const { date, period, status, user_id: requestedUserId } = req.body || {}; - if (!date || !PERIODS.includes(period) || !STATUSES.includes(status)) { + if (!isDate(date) || !PERIODS.includes(period) || !STATUSES.includes(status)) { return res.status(400).json({ error: 'Paramètres invalides' }); } - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); - - const d = new Date(date + 'T00:00:00Z'); - const year = d.getUTCFullYear(); - const month = d.getUTCMonth() + 1; - - const lock = await getMonthLock(target.id, year, month); - const companyValidation = await getCompanyValidation(target.company_id, year, month); - - if (companyValidation.admin_validated) { - return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' }); - } - if (req.user.role !== 'admin' && lock.cadre_validated) { - return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' }); - } + const target = await resolveTarget(req, res, requestedUserId); + if (!target) return; + if (!(await ensureWritable(req, res, target, ymOf(date)))) return; await db.query( `INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at) @@ -113,19 +116,14 @@ router.put('/', async (req, res) => { DO UPDATE SET status = EXCLUDED.status, updated_at = now()`, [target.id, date, period, status] ); - res.json({ ok: true }); }); // Per-month aggregates for the history view: half-day counts by status plus // both validation flags, most recent month first. router.get('/history', async (req, res) => { - const requestedUserId = req.query.user_id; - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); + const target = await resolveTarget(req, res, req.query.user_id); + if (!target) return; const { rows } = await db.query( `WITH months AS ( @@ -163,115 +161,64 @@ router.get('/history', async (req, res) => { }))); }); -// Clear every entry of a month (the « Tout effacer » action). Same lock rules -// as writes. +// Clear every entry of a month (the « Tout effacer » action). router.delete('/month', async (req, res) => { const { year, month, user_id: requestedUserId } = req.body || {}; - const y = parseInt(year, 10); - const m = parseInt(month, 10); - if (!y || !m || m < 1 || m > 12) return res.status(400).json({ error: 'Paramètres invalides' }); - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); + const ym = parseYearMonth(year, month); + if (!ym) return res.status(400).json({ error: 'Paramètres invalides' }); + const target = await resolveTarget(req, res, requestedUserId); + if (!target) return; + if (!(await ensureWritable(req, res, target, ym))) return; - const lock = await getMonthLock(target.id, y, m); - const companyValidation = await getCompanyValidation(target.company_id, y, m); - if (companyValidation.admin_validated) { - return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' }); - } - if (req.user.role !== 'admin' && lock.cadre_validated) { - return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' }); - } - - const start = `${y}-${String(m).padStart(2, '0')}-01`; await db.query( `DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`, - [target.id, start] + [target.id, monthStart(ym)] ); res.json({ ok: true }); }); -// Bulk upsert (e.g. « fill all empty weekdays with présent »). All entries -// must pass the same lock checks as single writes; months are checked once -// per distinct month present in the payload. +// Bulk upsert (e.g. « fill all empty weekdays with présent »). Every month +// present in the payload must pass the same lock checks as single writes. router.put('/bulk', async (req, res) => { const { entries, user_id: requestedUserId } = req.body || {}; if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200) { return res.status(400).json({ error: 'Paramètres invalides' }); } - for (const e of entries) { - if (!e || !e.date || !PERIODS.includes(e.period) || !STATUSES.includes(e.status)) { - return res.status(400).json({ error: 'Paramètres invalides' }); - } + if (entries.some((e) => !e || !isDate(e.date) || !PERIODS.includes(e.period) || !STATUSES.includes(e.status))) { + return res.status(400).json({ error: 'Paramètres invalides' }); } - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); + const target = await resolveTarget(req, res, requestedUserId); + if (!target) return; - const months = new Set(entries.map((e) => e.date.slice(0, 7))); - for (const ym of months) { - const [year, month] = ym.split('-').map(Number); - const lock = await getMonthLock(target.id, year, month); - const companyValidation = await getCompanyValidation(target.company_id, year, month); - if (companyValidation.admin_validated) { - return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' }); - } - if (req.user.role !== 'admin' && lock.cadre_validated) { - return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' }); - } + for (const ym of new Set(entries.map((e) => e.date.slice(0, 7)))) { + if (!(await ensureWritable(req, res, target, ymOf(ym)))) return; } - const client = await db.pool.connect(); - try { - await client.query('BEGIN'); - for (const e of entries) { - await client.query( - `INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at) - VALUES ($1, $2, $3, $4, now()) - ON CONFLICT (user_id, entry_date, period) - DO UPDATE SET status = EXCLUDED.status, updated_at = now()`, - [target.id, e.date, e.period, e.status] - ); - } - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } + // One statement for the whole batch. A half-day listed twice would make + // ON CONFLICT hit the same row twice and fail, so the last one wins. + const byKey = new Map(entries.map((e) => [`${e.date}|${e.period}`, e])); + const list = [...byKey.values()]; + await db.query( + `INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at) + SELECT $1, d, p::half_day_period, s::attendance_status, now() + FROM unnest($2::date[], $3::text[], $4::text[]) AS t(d, p, s) + ON CONFLICT (user_id, entry_date, period) + DO UPDATE SET status = EXCLUDED.status, updated_at = now()`, + [target.id, list.map((e) => e.date), list.map((e) => e.period), list.map((e) => e.status)] + ); - res.json({ ok: true, count: entries.length }); + res.json({ ok: true, count: list.length }); }); router.delete('/', async (req, res) => { const { date, period, user_id: requestedUserId } = req.body || {}; - if (!date || !PERIODS.includes(period)) { + if (!isDate(date) || !PERIODS.includes(period)) { return res.status(400).json({ error: 'Paramètres invalides' }); } - if (requestedUserId && req.user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé' }); - } - const target = await getTargetUser(req, requestedUserId); - if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' }); - - const d = new Date(date + 'T00:00:00Z'); - const year = d.getUTCFullYear(); - const month = d.getUTCMonth() + 1; - const lock = await getMonthLock(target.id, year, month); - const companyValidation = await getCompanyValidation(target.company_id, year, month); - - if (companyValidation.admin_validated) { - return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' }); - } - if (req.user.role !== 'admin' && lock.cadre_validated) { - return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' }); - } + const target = await resolveTarget(req, res, requestedUserId); + if (!target) return; + if (!(await ensureWritable(req, res, target, ymOf(date)))) return; await db.query( 'DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date = $2 AND period = $3', diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1b14935..61cae37 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -2,40 +2,81 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const db = require('../db'); const { signToken, setAuthCookie, clearAuthCookie, requireAuth } = require('../middleware/auth'); +const { passwordError } = require('../utils/validate'); const router = express.Router(); +// Brute-force guard: after MAX_FAILURES wrong passwords for one email within +// WINDOW_MS, further attempts on that email are refused until the window ends. +const MAX_FAILURES = 10; +const WINDOW_MS = 15 * 60 * 1000; +const failures = new Map(); // email -> { count, since } + +function lockedFor(email) { + const f = failures.get(email); + if (!f) return 0; + const left = f.since + WINDOW_MS - Date.now(); + if (left <= 0) { + failures.delete(email); + return 0; + } + return f.count >= MAX_FAILURES ? left : 0; +} + +function recordFailure(email) { + const now = Date.now(); + if (failures.size > 10000) { + for (const [k, f] of failures) if (f.since + WINDOW_MS <= now) failures.delete(k); + } + const f = failures.get(email); + if (!f || f.since + WINDOW_MS <= now) failures.set(email, { count: 1, since: now }); + else f.count += 1; +} + +// Compared against when the email is unknown, so that a wrong email and a +// wrong password take the same time and do not reveal which accounts exist. +const DUMMY_HASH = bcrypt.hashSync('presencia-dummy-password', 10); + +function publicUser(u) { + return { + id: u.id, + fullName: u.full_name, + email: u.email, + role: u.role, + companyId: u.company_id, + companyName: u.company_name, + }; +} + router.post('/login', async (req, res) => { const { email, password } = req.body || {}; - if (!email || !password) { + if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) { return res.status(400).json({ error: 'Email et mot de passe requis' }); } + const key = email.trim().toLowerCase(); + const wait = lockedFor(key); + if (wait) { + const minutes = Math.ceil(wait / 60000); + return res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${minutes} min.` }); + } + const { rows } = await db.query( `SELECT u.id, u.full_name, u.email, u.password_hash, u.role, u.active, u.company_id, c.name AS company_name FROM users u LEFT JOIN companies c ON c.id = u.company_id - WHERE lower(u.email) = lower($1)`, - [email] + WHERE lower(u.email) = $1`, + [key] ); const user = rows[0]; - if (!user || !user.active) { + const ok = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH); + if (!user || !ok || !user.active) { + recordFailure(key); return res.status(401).json({ error: 'Identifiants incorrects' }); } - const ok = await bcrypt.compare(password, user.password_hash); - if (!ok) { - return res.status(401).json({ error: 'Identifiants incorrects' }); - } - const token = signToken(user); - setAuthCookie(res, token); - res.json({ - id: user.id, - fullName: user.full_name, - email: user.email, - role: user.role, - companyId: user.company_id, - companyName: user.company_name, - }); + failures.delete(key); + setAuthCookie(res, signToken(user)); + res.json(publicUser(user)); }); router.post('/logout', (req, res) => { @@ -51,16 +92,29 @@ router.get('/me', requireAuth, async (req, res) => { WHERE u.id = $1`, [req.user.id] ); - if (!rows[0]) return res.status(401).json({ error: 'Non authentifié' }); - const u = rows[0]; - res.json({ - id: u.id, - fullName: u.full_name, - email: u.email, - role: u.role, - companyId: u.company_id, - companyName: u.company_name, - }); + res.json(publicUser(rows[0])); +}); + +// Any signed-in user changes their own password; the current one is required. +router.put('/password', requireAuth, async (req, res) => { + const { current, password } = req.body || {}; + const err = passwordError(password); + if (err) return res.status(400).json({ error: err }); + if (typeof current !== 'string' || !current) { + return res.status(400).json({ error: 'Mot de passe actuel requis' }); + } + const { rows } = await db.query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]); + if (!(await bcrypt.compare(current, rows[0].password_hash))) { + return res.status(400).json({ error: 'Mot de passe actuel incorrect' }); + } + const hash = await bcrypt.hash(password, 10); + await db.query( + 'UPDATE users SET password_hash = $1, password_changed_at = now() WHERE id = $2', + [hash, req.user.id] + ); + // Other sessions are now invalid; keep this one alive with a fresh token. + setAuthCookie(res, signToken(req.user)); + res.json({ ok: true }); }); module.exports = router; diff --git a/backend/src/routes/companies.js b/backend/src/routes/companies.js index 55c6ac9..352a190 100644 --- a/backend/src/routes/companies.js +++ b/backend/src/routes/companies.js @@ -1,9 +1,11 @@ const express = require('express'); const db = require('../db'); const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { isName, idParam } = require('../utils/validate'); const router = express.Router(); router.use(requireAuth, requireAdmin); +router.param('id', idParam); router.get('/', async (req, res) => { const { rows } = await db.query( @@ -19,7 +21,7 @@ router.get('/', async (req, res) => { router.post('/', async (req, res) => { const { name } = req.body || {}; - if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' }); + if (!isName(name)) return res.status(400).json({ error: 'Nom requis (255 caractères maximum)' }); try { const { rows } = await db.query( 'INSERT INTO companies (name) VALUES ($1) RETURNING id, name, created_at', @@ -34,7 +36,7 @@ router.post('/', async (req, res) => { router.put('/:id', async (req, res) => { const { name } = req.body || {}; - if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' }); + if (!isName(name)) return res.status(400).json({ error: 'Nom requis (255 caractères maximum)' }); try { const { rows } = await db.query( 'UPDATE companies SET name = $1 WHERE id = $2 RETURNING id, name, created_at', diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js index 5616c03..3fc6194 100644 --- a/backend/src/routes/export.js +++ b/backend/src/routes/export.js @@ -3,9 +3,11 @@ const db = require('../db'); const { requireAuth, requireAdmin } = require('../middleware/auth'); const { buildCompanyWorkbook } = require('../utils/excel'); const { buildCompanyPdf } = require('../utils/pdf'); +const { parseYearMonth, monthStart, idParam } = require('../utils/validate'); const router = express.Router(); router.use(requireAuth, requireAdmin); +router.param('companyId', idParam); async function loadCompanyData(companyId, year, month) { const { rows: companyRows } = await db.query('SELECT id, name FROM companies WHERE id = $1', [companyId]); @@ -17,28 +19,37 @@ async function loadCompanyData(companyId, year, month) { [companyId] ); - const start = `${year}-${String(month).padStart(2, '0')}-01`; - const cadres = []; - for (const c of cadreRows) { - const { rows: entries } = await db.query( - `SELECT entry_date, period, status FROM attendance_entries - WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`, - [c.id, start] - ); - const map = new Map(); - for (const e of entries) { - map.set(`${e.entry_date.toISOString().slice(0, 10)}:${e.period}`, e.status); - } - cadres.push({ id: c.id, fullName: c.full_name, entries: map }); + // All entries of the month in one query, rather than one query per cadre. + const { rows: entryRows } = await db.query( + `SELECT ae.user_id, ae.entry_date, ae.period, ae.status + FROM attendance_entries ae + JOIN users u ON u.id = ae.user_id + WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true + AND ae.entry_date >= $2::date AND ae.entry_date < ($2::date + INTERVAL '1 month')`, + [companyId, monthStart({ year, month })] + ); + const cadres = cadreRows.map((c) => ({ id: c.id, fullName: c.full_name, entries: new Map() })); + const byId = new Map(cadres.map((c) => [c.id, c])); + for (const e of entryRows) { + byId.get(e.user_id).entries.set(`${e.entry_date}:${e.period}`, e.status); } return { company, cadres }; } +// Content-Disposition value. Header values must be Latin-1, so a company +// name like « Société — Nord » would make Node throw: send an ASCII fallback +// plus the exact UTF-8 name (RFC 6266 / 5987). +function attachment(companyName, year, month, ext) { + const base = `presences_${companyName}_${year}-${String(month).padStart(2, '0')}.${ext}`.replace(/\s+/g, '_'); + const ascii = base.normalize('NFD').replace(/[^\x20-\x7e]/g, '').replace(/["\\/;]/g, '_'); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base.replace(/[\\/]/g, '_')).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`; +} + router.get('/excel/:companyId', async (req, res) => { - const year = parseInt(req.query.year, 10); - const month = parseInt(req.query.month, 10); - if (!year || !month) return res.status(400).json({ error: 'year et month requis' }); + const ym = parseYearMonth(req.query.year, req.query.month); + if (!ym) return res.status(400).json({ error: 'year et month requis' }); + const { year, month } = ym; const data = await loadCompanyData(req.params.companyId, year, month); if (!data) return res.status(404).json({ error: 'Société introuvable' }); @@ -50,24 +61,22 @@ router.get('/excel/:companyId', async (req, res) => { cadres: data.cadres, }); - const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.xlsx`; res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Disposition', attachment(data.company.name, year, month, 'xlsx')); await workbook.xlsx.write(res); res.end(); }); router.get('/pdf/:companyId', async (req, res) => { - const year = parseInt(req.query.year, 10); - const month = parseInt(req.query.month, 10); - if (!year || !month) return res.status(400).json({ error: 'year et month requis' }); + const ym = parseYearMonth(req.query.year, req.query.month); + if (!ym) return res.status(400).json({ error: 'year et month requis' }); + const { year, month } = ym; const data = await loadCompanyData(req.params.companyId, year, month); if (!data) return res.status(404).json({ error: 'Société introuvable' }); - const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.pdf`; res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Disposition', attachment(data.company.name, year, month, 'pdf')); const doc = buildCompanyPdf({ companyName: data.company.name, diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index eab574c..919df9f 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,109 +1,116 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const db = require('../db'); -const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { requireAuth, requireAdmin, signToken, setAuthCookie } = require('../middleware/auth'); +const { isId, isEmail, isName, passwordError, idParam } = require('../utils/validate'); const router = express.Router(); router.use(requireAuth, requireAdmin); +router.param('id', idParam); + +const USER_COLUMNS = 'id, full_name, email, role, company_id, active, created_at'; + +// Shared checks for create and update. Returns an error message or null. +function profileError({ full_name, email, role, company_id }) { + if (!isName(full_name) || !email || !role) return 'Champs requis manquants'; + if (!isEmail(String(email).trim())) return 'Adresse e-mail invalide'; + if (!['admin', 'cadre'].includes(role)) return 'Rôle invalide'; + if (role === 'cadre' && !isId(String(company_id ?? ''))) return 'Une société doit être attribuée au cadre'; + return null; +} + +function dbError(err, res) { + if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' }); + if (err.code === '23503') return res.status(400).json({ error: 'Société introuvable' }); + throw err; +} router.get('/', async (req, res) => { const { company_id } = req.query; - const params = []; - let where = ''; - if (company_id) { - params.push(company_id); - where = `WHERE u.company_id = $${params.length}`; - } + if (company_id && !isId(company_id)) return res.status(400).json({ error: 'Société invalide' }); const { rows } = await db.query( `SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.created_at, c.name AS company_name FROM users u LEFT JOIN companies c ON c.id = u.company_id - ${where} + ${company_id ? 'WHERE u.company_id = $1' : ''} ORDER BY u.full_name`, - params + company_id ? [company_id] : [] ); res.json(rows); }); router.post('/', async (req, res) => { - const { full_name, email, password, role, company_id } = req.body || {}; - if (!full_name || !email || !password || !role) { - return res.status(400).json({ error: 'Champs requis manquants' }); - } - if (!['admin', 'cadre'].includes(role)) { - return res.status(400).json({ error: 'Rôle invalide' }); - } - if (role === 'cadre' && !company_id) { - return res.status(400).json({ error: 'Une société doit être attribuée au cadre' }); - } + const body = req.body || {}; + const err = profileError(body) || passwordError(body.password); + if (err) return res.status(400).json({ error: err }); + const { full_name, email, password, role, company_id } = body; try { const hash = await bcrypt.hash(password, 10); const { rows } = await db.query( `INSERT INTO users (full_name, email, password_hash, role, company_id, active) VALUES ($1, $2, $3, $4, $5, true) - RETURNING id, full_name, email, role, company_id, active, created_at`, + RETURNING ${USER_COLUMNS}`, [full_name.trim(), email.trim().toLowerCase(), hash, role, role === 'admin' ? null : company_id] ); res.status(201).json(rows[0]); - } catch (err) { - if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' }); - throw err; + } catch (e) { + dbError(e, res); } }); router.put('/:id', async (req, res) => { - const { full_name, email, role, company_id, active } = req.body || {}; - if (!full_name || !email || !role) { - return res.status(400).json({ error: 'Champs requis manquants' }); - } - if (!['admin', 'cadre'].includes(role)) { - return res.status(400).json({ error: 'Rôle invalide' }); - } - if (role === 'cadre' && !company_id) { - return res.status(400).json({ error: 'Une société doit être attribuée au cadre' }); + const body = req.body || {}; + const err = profileError(body); + if (err) return res.status(400).json({ error: err }); + const { full_name, email, role, company_id } = body; + const active = body.active !== false; + // An admin locking themselves out (or the last admin disappearing) can only + // be undone directly in the database. + if (String(req.user.id) === req.params.id && (!active || role !== 'admin')) { + return res.status(400).json({ error: 'Vous ne pouvez pas désactiver ni rétrograder votre propre compte' }); } try { const { rows } = await db.query( `UPDATE users SET full_name = $1, email = $2, role = $3, company_id = $4, active = $5 WHERE id = $6 - RETURNING id, full_name, email, role, company_id, active, created_at`, - [ - full_name.trim(), - email.trim().toLowerCase(), - role, - role === 'admin' ? null : company_id, - active !== false, - req.params.id, - ] + RETURNING ${USER_COLUMNS}`, + [full_name.trim(), email.trim().toLowerCase(), role, role === 'admin' ? null : company_id, active, req.params.id] ); if (!rows[0]) return res.status(404).json({ error: 'Utilisateur introuvable' }); res.json(rows[0]); - } catch (err) { - if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' }); - throw err; + } catch (e) { + dbError(e, res); } }); router.put('/:id/password', async (req, res) => { const { password } = req.body || {}; - if (!password || password.length < 6) { - return res.status(400).json({ error: 'Mot de passe trop court (6 caractères minimum)' }); - } + const err = passwordError(password); + if (err) return res.status(400).json({ error: err }); const hash = await bcrypt.hash(password, 10); - const { rowCount } = await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [ - hash, - req.params.id, - ]); + // Also ends that user's open sessions. + const { rowCount } = await db.query( + 'UPDATE users SET password_hash = $1, password_changed_at = now() WHERE id = $2', + [hash, req.params.id] + ); if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' }); + if (String(req.user.id) === req.params.id) setAuthCookie(res, signToken(req.user)); res.json({ ok: true }); }); router.delete('/:id', async (req, res) => { - if (String(req.user.id) === String(req.params.id)) { + if (String(req.user.id) === req.params.id) { return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); } - const { rowCount } = await db.query('DELETE FROM users WHERE id = $1', [req.params.id]); + let rowCount; + try { + ({ rowCount } = await db.query('DELETE FROM users WHERE id = $1', [req.params.id])); + } catch (e) { + // Referenced as the author of a company validation. + if (e.code === '23503') return res.status(409).json({ error: 'Compte référencé par des validations : désactivez-le plutôt' }); + throw e; + } if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' }); res.json({ ok: true }); }); diff --git a/backend/src/routes/validations.js b/backend/src/routes/validations.js index 604a0e1..ec6e5d8 100644 --- a/backend/src/routes/validations.js +++ b/backend/src/routes/validations.js @@ -1,15 +1,14 @@ const express = require('express'); const db = require('../db'); const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { isId, parseYearMonth: parseYM, monthStart, idParam } = require('../utils/validate'); const router = express.Router(); router.use(requireAuth); +router.param('companyId', idParam); function parseYearMonth(req) { - const year = parseInt(req.body?.year ?? req.query?.year, 10); - const month = parseInt(req.body?.month ?? req.query?.month, 10); - if (!year || !month || month < 1 || month > 12) return null; - return { year, month }; + return parseYM(req.body?.year ?? req.query?.year, req.body?.month ?? req.query?.month); } // Cadre validates their own month. @@ -19,6 +18,7 @@ router.post('/cadre', async (req, res) => { // Admin can validate on behalf of a cadre (e.g. corrections), otherwise self only. const targetUserId = req.user.role === 'admin' && req.body.user_id ? req.body.user_id : req.user.id; + if (!isId(String(targetUserId))) return res.status(400).json({ error: 'Utilisateur invalide' }); const { rows: userRows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [targetUserId]); const target = userRows[0]; @@ -46,7 +46,7 @@ router.post('/cadre', async (req, res) => { router.post('/cadre/reopen', requireAdmin, async (req, res) => { const ym = parseYearMonth(req); const { user_id } = req.body || {}; - if (!ym || !user_id) return res.status(400).json({ error: 'Paramètres invalides' }); + if (!ym || !isId(String(user_id ?? ''))) return res.status(400).json({ error: 'Paramètres invalides' }); await db.query( `INSERT INTO month_locks (user_id, year, month, cadre_validated, cadre_validated_at) @@ -60,12 +60,11 @@ router.post('/cadre/reopen', requireAdmin, async (req, res) => { // Admin: status of every cadre in a company for a given month. router.get('/company/:companyId', requireAdmin, async (req, res) => { - const year = parseInt(req.query.year, 10); - const month = parseInt(req.query.month, 10); + const ym = parseYearMonth(req); + if (!ym) return res.status(400).json({ error: 'year et month requis' }); + const { year, month } = ym; const companyId = req.params.companyId; - if (!year || !month) return res.status(400).json({ error: 'year et month requis' }); - - const start = `${year}-${String(month).padStart(2, '0')}-01`; + const start = monthStart(ym); const { rows: cadres } = await db.query( `SELECT u.id, u.full_name, u.email, ml.cadre_validated, ml.cadre_validated_at, @@ -103,9 +102,9 @@ router.get('/company/:companyId', requireAdmin, async (req, res) => { // Admin: one-shot overview of every company for a given month (dashboard). router.get('/overview', requireAdmin, async (req, res) => { - const year = parseInt(req.query.year, 10); - const month = parseInt(req.query.month, 10); - if (!year || !month) return res.status(400).json({ error: 'year et month requis' }); + const ym = parseYearMonth(req); + if (!ym) return res.status(400).json({ error: 'year et month requis' }); + const { year, month } = ym; const { rows } = await db.query( `SELECT c.id, c.name, diff --git a/backend/src/utils/validate.js b/backend/src/utils/validate.js new file mode 100644 index 0000000..044c93d --- /dev/null +++ b/backend/src/utils/validate.js @@ -0,0 +1,56 @@ +// Input checks shared by the routes. Anything that reaches SQL unchecked and +// is malformed (an id like "abc", a month 13, a date "2026-02-31") makes +// Postgres throw, which surfaces as a 500 instead of a 400. + +const MAX_INT = 2147483647; + +function isId(v) { + if (typeof v === 'number') return Number.isInteger(v) && v > 0 && v <= MAX_INT; + return typeof v === 'string' && /^\d{1,10}$/.test(v) && Number(v) > 0 && Number(v) <= MAX_INT; +} + +function isDate(s) { + if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; + const d = new Date(`${s}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; +} + +// Returns { year, month } or null. +function parseYearMonth(year, month) { + const y = parseInt(year, 10); + const m = parseInt(month, 10); + if (!(y >= 2000 && y <= 2100) || !(m >= 1 && m <= 12)) return null; + return { year: y, month: m }; +} + +function monthStart({ year, month }) { + return `${year}-${String(month).padStart(2, '0')}-01`; +} + +function isEmail(s) { + return typeof s === 'string' && s.length <= 255 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); +} + +function isName(s) { + return typeof s === 'string' && s.trim().length > 0 && s.trim().length <= 255; +} + +const MIN_PASSWORD = 8; +function passwordError(p) { + if (typeof p !== 'string' || p.length < MIN_PASSWORD) { + return `Mot de passe trop court (${MIN_PASSWORD} caractères minimum)`; + } + // bcrypt ignores everything past 72 bytes. + if (Buffer.byteLength(p) > 72) return 'Mot de passe trop long (72 octets maximum)'; + return null; +} + +// Express router.param handler: rejects non-numeric ids with a 400. +function idParam(req, res, next, value) { + if (!isId(value)) return res.status(400).json({ error: 'Identifiant invalide' }); + next(); +} + +module.exports = { + isId, isDate, parseYearMonth, monthStart, isEmail, isName, passwordError, idParam, MIN_PASSWORD, +}; diff --git a/docker-compose.yml b/docker-compose.yml index d7145dd..91beadc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,8 @@ services: volumes: - presencia_pgdata:/var/lib/postgresql/data ports: - - "6543:5432" + # Reachable from this host only (psql, backups), not from the network. + - "127.0.0.1:6543:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U presencia"] interval: 5s @@ -29,13 +30,16 @@ services: PGUSER: presencia PGPASSWORD: R4dsITu0vuigNQQwNlvj0agVyHupSyX PGDATABASE: presencia - JWT_SECRET: change-me-to-a-long-random-string + # Leave empty to let the API generate a random secret, stored in the + # database. If set, it must be at least 32 random characters. + JWT_SECRET: "" COOKIE_SECURE: "false" ADMIN_EMAIL: michaelschal@ffest.fr ADMIN_PASSWORD: Lapîn2509 ADMIN_NAME: Michael ports: - - "4790:4790" + # The browser goes through the frontend; direct API access stays local. + - "127.0.0.1:4790:4790" presencia-frontend: build: ./frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 93dca21..72ec47b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,7 @@ FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY security-headers.conf /etc/nginx/snippets/security-headers.conf COPY public /usr/share/nginx/html EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6e5d8e1..b0a2d44 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,7 @@ server { root /usr/share/nginx/html; index index.html; + server_tokens off; # Resolve the backend hostname at request time (via Docker's embedded DNS) # instead of once at startup, so a recreated backend container is picked @@ -24,6 +25,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # The API sets these too (for direct access); keep a single copy. + proxy_hide_header X-Frame-Options; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header Referrer-Policy; + include /etc/nginx/snippets/security-headers.conf; } # index.html, app.js and the stylesheets keep the same filenames across @@ -34,16 +40,19 @@ server { # it only forces a revalidation, so unchanged files cost one 304. location ~* \.(?:html|js|css)$ { add_header Cache-Control "no-cache"; + include /etc/nginx/snippets/security-headers.conf; try_files $uri $uri/ /index.html; } # Fonts are content-stable and large; let them sit in the browser cache. location ~* \.(?:woff2|woff|ttf)$ { add_header Cache-Control "public, max-age=2592000"; + include /etc/nginx/snippets/security-headers.conf; } location / { add_header Cache-Control "no-cache"; + include /etc/nginx/snippets/security-headers.conf; try_files $uri $uri/ /index.html; } } diff --git a/frontend/public/css/app.css b/frontend/public/css/app.css index 6fd9e9a..a3374d3 100644 --- a/frontend/public/css/app.css +++ b/frontend/public/css/app.css @@ -52,9 +52,12 @@ body { margin: 0; } /* ── mobile chrome ── */ .mobile-head { align-items: center; gap: 10px; margin-bottom: 14px; } .mobile-title { font-size: 15px; font-weight: 500; margin-right: auto; } -.mobile-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--color-surface); border-top: 1px solid var(--color-divider); padding: 6px 8px; justify-content: space-around; } -.mobile-nav .navitem { flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 4px; font-size: 10px; } +.mobile-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--color-surface); border-top: 1px solid var(--color-divider); padding: 6px 4px calc(6px + env(safe-area-inset-bottom)); justify-content: space-around; gap: 2px; overflow-x: auto; } +.mobile-nav .navitem { position: relative; flex: 1 1 0; min-width: 52px; flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 2px; font-size: 10px; white-space: nowrap; } .mobile-nav .navitem .ph { font-size: 19px; } +.mobile-nav .navitem .ni-badge { position: absolute; top: 2px; left: calc(50% + 6px); margin: 0; padding: 0 5px; font-size: 10px; line-height: 15px; } +.mobile-head .chipbtn .ph { font-size: 15px; } +.chipbtn-icon { padding-inline: 9px; } /* ── generic stacks / heads ── */ .stack-20 { display: flex; flex-direction: column; gap: 20px; } @@ -72,6 +75,40 @@ body { margin: 0; } .cell-muted { color: var(--color-neutral-700); } .cell-actions { text-align: right; white-space: nowrap; } .empty-row td { color: var(--color-neutral-700); font-size: 13px; } +.cell-capitalize { text-transform: capitalize; } +.field-hint { display: block; margin-top: 4px; font-size: 12px; color: var(--color-neutral-700); } + +/* destructive actions read as such without shouting */ +.btn-danger-ghost { color: #a8412c; } +.btn-danger-ghost:hover:not(:disabled) { background: #fbeae6; } + +/* pending request on a button (forms, exports) */ +.btn[aria-busy="true"], .btn:disabled { cursor: progress; } +.btn:disabled:not([aria-busy]) { cursor: not-allowed; } + +/* ── tables as cards on phones ── + A 5–6 column table cannot fit 390 px; scrolling it sideways hides the + actions at the far end. Each row becomes a card instead, every cell + labelled with its column title (data-label, set by fillTable()). */ +@media (max-width: 640px) { + .content table.table { min-width: 0; } + .content .card:has(> table.table) { overflow-x: visible; } + .table-card { padding: 4px 12px; } + .table thead { display: none; } + .table, .table tbody, .table tr, .table td { display: block; width: 100%; } + .table tbody tr { padding: 10px 0; } + .table tbody tr:hover { background: linear-gradient(color-mix(in srgb, var(--color-text) 8%, transparent), color-mix(in srgb, var(--color-text) 8%, transparent)) no-repeat bottom / 100% 1px; } + .table tbody tr:last-child { background: none; } + .table td { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 3px 0; text-align: right; min-width: 0; overflow-wrap: anywhere; } + .table td::before { content: attr(data-label); flex: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--color-neutral-700); text-align: left; } + .table td:first-child { font-weight: 500; justify-content: flex-start; text-align: left; } + .table td:first-child::before { display: none; } + .table td.cell-actions { justify-content: flex-end; flex-wrap: wrap; gap: 4px; white-space: normal; padding-top: 6px; } + .table td.cell-actions::before { display: none; } + .table td.cell-actions .btn { min-height: 36px; } + .empty-row td { justify-content: flex-start; text-align: left; } + .empty-row td::before { display: none; } +} /* ── dashboard ── */ .tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } diff --git a/frontend/public/index.html b/frontend/public/index.html index 56a8d93..d92a952 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -65,6 +65,7 @@ + @@ -73,7 +74,8 @@