mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-11 17:27:42 +02:00
Corrections issues de la revue de code
- Limite de connexion par (e-mail, IP) et par IP : un tiers ne peut plus bloquer le compte admin depuis une autre adresse ; mémoire bornée. - Ids canoniques uniquement (« 07 » contournait la protection du propre compte de l'admin). - Export : un cadre réactivé entre deux requêtes ne fait plus planter. - /auth/me lit le compte déjà chargé par requireAuth ; /auth/password et e-mail non textuel ne renvoient plus 500. - Planning : une écriture réussie n'est plus perdue quand un rechargement du même mois répond avant elle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5Bdayziw6tybgqETZoNSt
This commit is contained in:
7 files changed
+68
-41
No files matched your search
@@ -16,6 +16,11 @@ const app = express();
|
|||||||
const PORT = process.env.PORT || 4790;
|
const PORT = process.env.PORT || 4790;
|
||||||
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
// Requests arrive through nginx (and often a reverse proxy in front of it),
|
||||||
|
// both on private Docker networks: take the client address from
|
||||||
|
// X-Forwarded-For, trusting only private-network hops so it cannot be spoofed
|
||||||
|
// from the Internet. Used by the login rate limit.
|
||||||
|
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||||
// The API is also reachable on its own port, without the nginx headers.
|
// The API is also reachable on its own port, without the nginx headers.
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.set({
|
res.set({
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ async function requireAuth(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
||||||
}
|
}
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT id, full_name, email, role, company_id, active, password_changed_at
|
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.password_changed_at,
|
||||||
FROM users WHERE id = $1`,
|
c.name AS company_name
|
||||||
|
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||||
|
WHERE u.id = $1`,
|
||||||
[payload.id]
|
[payload.id]
|
||||||
);
|
);
|
||||||
const u = rows[0];
|
const u = rows[0];
|
||||||
@@ -80,6 +82,7 @@ async function requireAuth(req, res, next) {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
companyId: u.company_id,
|
companyId: u.company_id,
|
||||||
|
companyName: u.company_name,
|
||||||
fullName: u.full_name,
|
fullName: u.full_name,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
};
|
};
|
||||||
|
|||||||
+43
-31
@@ -6,31 +6,47 @@ const { passwordError } = require('../utils/validate');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Brute-force guard: after MAX_FAILURES wrong passwords for one email within
|
// Brute-force guard, two counters over WINDOW_MS:
|
||||||
// WINDOW_MS, further attempts on that email are refused until the window ends.
|
// - per (email, IP): guessing one account's password. Keyed by IP too, so
|
||||||
const MAX_FAILURES = 10;
|
// that someone failing on purpose cannot lock the real owner out from
|
||||||
|
// another address;
|
||||||
|
// - per IP, all emails together: trying a few passwords on many accounts.
|
||||||
const WINDOW_MS = 15 * 60 * 1000;
|
const WINDOW_MS = 15 * 60 * 1000;
|
||||||
const failures = new Map(); // email -> { count, since }
|
const MAX_PER_ACCOUNT = 10;
|
||||||
|
const MAX_PER_IP = 50;
|
||||||
|
const MAX_KEYS = 50000; // bounds memory whatever the attempt volume
|
||||||
|
const failures = new Map(); // key -> { count, since }
|
||||||
|
|
||||||
function lockedFor(email) {
|
function failuresOf(key, now) {
|
||||||
const f = failures.get(email);
|
const f = failures.get(key);
|
||||||
if (!f) return 0;
|
if (f && f.since + WINDOW_MS > now) return f;
|
||||||
const left = f.since + WINDOW_MS - Date.now();
|
if (f) failures.delete(key);
|
||||||
if (left <= 0) {
|
return null;
|
||||||
failures.delete(email);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return f.count >= MAX_FAILURES ? left : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordFailure(email) {
|
// Milliseconds until a new attempt is allowed, 0 if allowed now.
|
||||||
|
function lockedFor(email, ip) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (failures.size > 10000) {
|
let wait = 0;
|
||||||
for (const [k, f] of failures) if (f.since + WINDOW_MS <= now) failures.delete(k);
|
for (const [key, max] of [[`a:${ip}:${email}`, MAX_PER_ACCOUNT], [`i:${ip}`, MAX_PER_IP]]) {
|
||||||
|
const f = failuresOf(key, now);
|
||||||
|
if (f && f.count >= max) wait = Math.max(wait, f.since + WINDOW_MS - now);
|
||||||
|
}
|
||||||
|
return wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(email, ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of [`a:${ip}:${email}`, `i:${ip}`]) {
|
||||||
|
const f = failuresOf(key, now);
|
||||||
|
if (f) {
|
||||||
|
f.count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Map keeps insertion order: the first key is the oldest window.
|
||||||
|
if (failures.size >= MAX_KEYS) failures.delete(failures.keys().next().value);
|
||||||
|
failures.set(key, { count: 1, since: now });
|
||||||
}
|
}
|
||||||
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
|
// Compared against when the email is unknown, so that a wrong email and a
|
||||||
@@ -54,7 +70,7 @@ router.post('/login', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Email et mot de passe requis' });
|
return res.status(400).json({ error: 'Email et mot de passe requis' });
|
||||||
}
|
}
|
||||||
const key = email.trim().toLowerCase();
|
const key = email.trim().toLowerCase();
|
||||||
const wait = lockedFor(key);
|
const wait = lockedFor(key, req.ip);
|
||||||
if (wait) {
|
if (wait) {
|
||||||
const minutes = Math.ceil(wait / 60000);
|
const minutes = Math.ceil(wait / 60000);
|
||||||
return res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${minutes} min.` });
|
return res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${minutes} min.` });
|
||||||
@@ -71,10 +87,10 @@ router.post('/login', async (req, res) => {
|
|||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
const ok = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH);
|
const ok = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH);
|
||||||
if (!user || !ok || !user.active) {
|
if (!user || !ok || !user.active) {
|
||||||
recordFailure(key);
|
recordFailure(key, req.ip);
|
||||||
return res.status(401).json({ error: 'Identifiants incorrects' });
|
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||||
}
|
}
|
||||||
failures.delete(key);
|
failures.delete(`a:${req.ip}:${key}`);
|
||||||
setAuthCookie(res, signToken(user));
|
setAuthCookie(res, signToken(user));
|
||||||
res.json(publicUser(user));
|
res.json(publicUser(user));
|
||||||
});
|
});
|
||||||
@@ -84,15 +100,10 @@ router.post('/logout', (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', requireAuth, async (req, res) => {
|
// requireAuth has just read the account from the database.
|
||||||
const { rows } = await db.query(
|
router.get('/me', requireAuth, (req, res) => {
|
||||||
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, c.name AS company_name
|
const { id, fullName, email, role, companyId, companyName } = req.user;
|
||||||
FROM users u
|
res.json({ id, fullName, email, role, companyId, companyName });
|
||||||
LEFT JOIN companies c ON c.id = u.company_id
|
|
||||||
WHERE u.id = $1`,
|
|
||||||
[req.user.id]
|
|
||||||
);
|
|
||||||
res.json(publicUser(rows[0]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Any signed-in user changes their own password; the current one is required.
|
// Any signed-in user changes their own password; the current one is required.
|
||||||
@@ -104,6 +115,7 @@ router.put('/password', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Mot de passe actuel requis' });
|
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]);
|
const { rows } = await db.query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (!rows[0]) return res.status(401).json({ error: 'Session invalide ou expirée' });
|
||||||
if (!(await bcrypt.compare(current, rows[0].password_hash))) {
|
if (!(await bcrypt.compare(current, rows[0].password_hash))) {
|
||||||
return res.status(400).json({ error: 'Mot de passe actuel incorrect' });
|
return res.status(400).json({ error: 'Mot de passe actuel incorrect' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ async function loadCompanyData(companyId, year, month) {
|
|||||||
const cadres = cadreRows.map((c) => ({ id: c.id, fullName: c.full_name, entries: new Map() }));
|
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]));
|
const byId = new Map(cadres.map((c) => [c.id, c]));
|
||||||
for (const e of entryRows) {
|
for (const e of entryRows) {
|
||||||
byId.get(e.user_id).entries.set(`${e.entry_date}:${e.period}`, e.status);
|
// a cadre (re)activated between the two queries is simply left out
|
||||||
|
const cadre = byId.get(e.user_id);
|
||||||
|
if (cadre) cadre.entries.set(`${e.entry_date}:${e.period}`, e.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { company, cadres };
|
return { company, cadres };
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ 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.
|
// Shared checks for create and update. Returns an error message or null.
|
||||||
function profileError({ full_name, email, role, company_id }) {
|
function profileError({ full_name, email, role, company_id }) {
|
||||||
if (!isName(full_name) || !email || !role) return 'Champs requis manquants';
|
if (!isName(full_name) || typeof email !== 'string' || !email || !role) return 'Champs requis manquants';
|
||||||
if (!isEmail(String(email).trim())) return 'Adresse e-mail invalide';
|
if (!isEmail(email.trim())) return 'Adresse e-mail invalide';
|
||||||
if (!['admin', 'cadre'].includes(role)) return 'Rôle 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';
|
if (role === 'cadre' && !isId(String(company_id ?? ''))) return 'Une société doit être attribuée au cadre';
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const MAX_INT = 2147483647;
|
|||||||
|
|
||||||
function isId(v) {
|
function isId(v) {
|
||||||
if (typeof v === 'number') return Number.isInteger(v) && v > 0 && v <= MAX_INT;
|
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;
|
// Canonical form only: '07' would reach SQL as 7 yet differ from '7' in
|
||||||
|
// the string comparisons that guard an admin's own account.
|
||||||
|
return typeof v === 'string' && /^[1-9]\d{0,9}$/.test(v) && Number(v) <= MAX_INT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDate(s) {
|
function isDate(s) {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
try { data = await res.json(); } catch (e) { /* empty body */ }
|
try { data = await res.json(); } catch (e) { /* empty body */ }
|
||||||
// The session ended under us (expired, password reset, account
|
// The session ended under us (expired, password reset, account
|
||||||
// disabled): go back to the login screen instead of failing every call.
|
// disabled): go back to the login screen instead of failing every call.
|
||||||
if (res.status === 401 && state.user && !path.startsWith('/auth/')) {
|
if (res.status === 401 && state.user && path !== '/auth/login') {
|
||||||
location.reload();
|
location.reload();
|
||||||
return new Promise(() => {});
|
return new Promise(() => {});
|
||||||
}
|
}
|
||||||
@@ -490,6 +490,7 @@
|
|||||||
if (state.viewing) qs.set('user_id', state.viewing.id);
|
if (state.viewing) qs.set('user_id', state.viewing.id);
|
||||||
const cal = await api(`/attendance?${qs}`);
|
const cal = await api(`/attendance?${qs}`);
|
||||||
if (req !== planningReq) return;
|
if (req !== planningReq) return;
|
||||||
|
cal.key = qs.toString();
|
||||||
state.cal = cal;
|
state.cal = cal;
|
||||||
renderPlanning();
|
renderPlanning();
|
||||||
}
|
}
|
||||||
@@ -624,10 +625,12 @@
|
|||||||
|
|
||||||
// After a successful write, apply it to the month already in memory
|
// After a successful write, apply it to the month already in memory
|
||||||
// instead of reloading the month: painting stays fluid. `cal` is the month
|
// instead of reloading the month: painting stays fluid. `cal` is the month
|
||||||
// the write was made on; if the user moved to another month meanwhile,
|
// the write was made on. It is matched by month and person, not by object:
|
||||||
// there is nothing to patch.
|
// a reload that answered before this write landed must get it too, while a
|
||||||
|
// different month on screen must not.
|
||||||
function patchEntries(cal, changes) {
|
function patchEntries(cal, changes) {
|
||||||
if (state.cal !== cal) return;
|
if (!state.cal || state.cal.key !== cal.key) return;
|
||||||
|
cal = state.cal;
|
||||||
const map = new Map((cal.entries || []).map((e) => [`${e.date}|${e.period}`, e]));
|
const map = new Map((cal.entries || []).map((e) => [`${e.date}|${e.period}`, e]));
|
||||||
changes.forEach(({ date, period, status }) => {
|
changes.forEach(({ date, period, status }) => {
|
||||||
if (status) map.set(`${date}|${period}`, { date, period, status });
|
if (status) map.set(`${date}|${period}`, { date, period, status });
|
||||||
|
|||||||
Reference in new issue
Block a user