Modification des pages d'authentification
This commit is contained in:
+101
-76
@@ -939,7 +939,6 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_s
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
// ── Table user_preferences ───────────────────────────────────────────────────
|
||||
// Stockage générique des préférences UI par utilisateur.
|
||||
@@ -1671,85 +1670,111 @@ db.exec(`
|
||||
console.log('[DB] Tables catégories/secteurs plateforme+investissement OK');
|
||||
}
|
||||
|
||||
// ── Migration ponctuelle : correction date_cible aberrantes (>2100) ──────────
|
||||
// Certains prêts différés importés ont une date_cible avec un siècle erroné.
|
||||
// On recalcule date_souscription + duree_mois et on régénère la simulation.
|
||||
|
||||
// ── Migration : table de configuration SMTP ──────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS smtp_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 587,
|
||||
secure INTEGER NOT NULL DEFAULT 0,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
allow_unauth INTEGER NOT NULL DEFAULT 0,
|
||||
app_name TEXT NOT NULL DEFAULT 'Crowdlending Tracker',
|
||||
app_url TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Migration : email_verified sur users ─────────────────────────────────────
|
||||
{
|
||||
function fixAddMonths(isoDate, months) {
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
let nm = m + months;
|
||||
let ny = y;
|
||||
while (nm > 12) { nm -= 12; ny++; }
|
||||
const maxDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate();
|
||||
const nd = Math.min(d, maxDay);
|
||||
return `${String(ny).padStart(4,'0')}-${String(nm).padStart(2,'0')}-${String(nd).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
const toFix = db.prepare(`
|
||||
SELECT i.id, i.date_souscription, i.duree_mois,
|
||||
i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets,
|
||||
i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois
|
||||
FROM investissements i
|
||||
WHERE i.statut IN ('en_cours','en_retard','procedure')
|
||||
AND i.type_remb = 'differe'
|
||||
AND i.date_cible > '2100-01-01'
|
||||
AND i.duree_mois IS NOT NULL
|
||||
`).all();
|
||||
|
||||
if (toFix.length > 0) {
|
||||
const updateDate = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`);
|
||||
const fixAll = db.transaction(() => {
|
||||
for (const inv of toFix) {
|
||||
const newDate = fixAddMonths(inv.date_souscription, inv.duree_mois);
|
||||
updateDate.run(newDate, inv.id);
|
||||
generateSimul(db, { ...inv, date_cible: newDate });
|
||||
console.log(`[DB] Fix date_cible id=${inv.id} → ${newDate}`);
|
||||
}
|
||||
});
|
||||
fixAll();
|
||||
console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`);
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
if (!userCols.includes('email_verified')) {
|
||||
// DEFAULT 1 pour ne pas bloquer les comptes existants
|
||||
db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1');
|
||||
console.log('[DB] users.email_verified ajouté');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : table smtp_config ─────────────────────────────────────────
|
||||
// ── Migration : table email_verification_tokens ───────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Migration : table password_reset_tokens ───────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Migrations 2FA ────────────────────────────────────────────────────────────
|
||||
{
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS smtp_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
host TEXT,
|
||||
port INTEGER NOT NULL DEFAULT 587,
|
||||
secure INTEGER NOT NULL DEFAULT 0,
|
||||
email TEXT,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
allow_unauth INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Seed row unique (id=1) si elle n'existe pas encore
|
||||
const existing = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get();
|
||||
if (!existing) {
|
||||
// Pré-remplir depuis les variables d'environnement si disponibles
|
||||
db.prepare(`
|
||||
INSERT INTO smtp_config (id, enabled, host, port, email, username, password)
|
||||
VALUES (1, 0, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
process.env.SMTP_HOST || null,
|
||||
parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
process.env.SMTP_EMAIL || null,
|
||||
process.env.SMTP_USERNAME || null,
|
||||
process.env.SMTP_PASSWORD || null,
|
||||
);
|
||||
const userCols2 = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
if (!userCols2.includes('totp_secret')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT');
|
||||
console.log('[DB] users.totp_secret ajouté');
|
||||
}
|
||||
if (!userCols2.includes('totp_enabled')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[DB] users.totp_enabled ajouté');
|
||||
}
|
||||
|
||||
// Ajout des colonnes app_name et app_url si absentes
|
||||
const smtpCols = db.prepare('PRAGMA table_info(smtp_config)').all().map(c => c.name);
|
||||
if (!smtpCols.includes('app_name'))
|
||||
db.exec(`ALTER TABLE smtp_config ADD COLUMN app_name TEXT DEFAULT 'Crowdlending'`);
|
||||
if (!smtpCols.includes('app_url'))
|
||||
db.exec(`ALTER TABLE smtp_config ADD COLUMN app_url TEXT DEFAULT ''`);
|
||||
|
||||
console.log('[DB] Table smtp_config OK');
|
||||
}
|
||||
|
||||
// Sessions temporaires 2FA (entre /login et /2fa/verify)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS two_fa_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_sess_token ON two_fa_sessions(token)');
|
||||
|
||||
// Codes OTP envoyés par email
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS two_fa_email_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_email_uid ON two_fa_email_codes(user_id)');
|
||||
|
||||
// Appareils de confiance (30 jours)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS two_fa_trusted_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_token ON two_fa_trusted_devices(token)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(user_id)');
|
||||
|
||||
console.log('[DB] Migrations 2FA OK');
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -48,13 +48,24 @@ const router = Router();
|
||||
/** Liste tous les utilisateurs */
|
||||
router.get('/users', (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT id, email, display_name, role, created_at
|
||||
SELECT id, email, display_name, role, email_verified, created_at
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
/** Vérifier manuellement l'email d'un utilisateur */
|
||||
router.patch('/users/:id/verify-email', (req, res, next) => {
|
||||
try {
|
||||
const targetId = Number(req.params.id);
|
||||
const r = db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(targetId);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(targetId);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Crée un utilisateur */
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -387,7 +398,7 @@ router.get('/smtp', (req, res) => {
|
||||
username: row.username || '',
|
||||
hasPassword: !!(row.password),
|
||||
allowUnauth: !!row.allow_unauth,
|
||||
appName: row.app_name || 'Crowdlending',
|
||||
appName: row.app_name || 'Crowdlending Tracker',
|
||||
appUrl: row.app_url || '',
|
||||
});
|
||||
});
|
||||
@@ -450,7 +461,7 @@ router.put('/smtp', (req, res, next) => {
|
||||
body.username ?? null,
|
||||
passwordToStore,
|
||||
body.allowUnauth ? 1 : 0,
|
||||
body.appName ?? 'Crowdlending',
|
||||
body.appName ?? 'Crowdlending Tracker',
|
||||
body.appUrl ?? '',
|
||||
);
|
||||
|
||||
|
||||
+406
-18
@@ -1,9 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,7 +20,7 @@ const LoginSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/register', (req, res, next) => {
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const body = RegisterSchema.parse(req.body);
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email);
|
||||
@@ -28,10 +30,15 @@ router.post('/register', (req, res, next) => {
|
||||
const isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0;
|
||||
const role = isFirst ? 'admin' : 'user';
|
||||
|
||||
// Le 1er utilisateur (admin) est auto-vérifié ; les suivants si SMTP désactivé
|
||||
const cfg = getSmtpConfig();
|
||||
const smtpReady = cfg.enabled && cfg.host && cfg.email;
|
||||
const autoVerified = isFirst || !smtpReady ? 1 : 0;
|
||||
|
||||
const hash = bcrypt.hashSync(body.password, 10);
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
||||
.run(body.email, hash, body.displayName || null, role);
|
||||
.prepare('INSERT INTO users (email, password_hash, display_name, role, email_verified) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(body.email, hash, body.displayName || null, role, autoVerified);
|
||||
|
||||
const userId = result.lastInsertRowid;
|
||||
|
||||
@@ -47,36 +54,104 @@ router.post('/register', (req, res, next) => {
|
||||
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
||||
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
||||
|
||||
// Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue
|
||||
if (!autoVerified) {
|
||||
const vToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(userId, vToken, expiresAt);
|
||||
|
||||
const appUrl = cfg.appUrl?.replace(/\/$/, '') || '';
|
||||
try {
|
||||
await sendMail({
|
||||
to: body.email,
|
||||
subject: `Bienvenue sur ${cfg.appName} — Vérifiez votre adresse email`,
|
||||
html: buildEmailHtml({
|
||||
title: `Bienvenue ${prenom || ''} !`,
|
||||
body: `<p>Votre compte a été créé avec succès. Pour commencer à utiliser ${cfg.appName}, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.</p>
|
||||
<p>Ce lien est valable <strong>24 heures</strong>.</p>`,
|
||||
ctaLabel: 'Vérifier mon adresse email',
|
||||
ctaUrl: `${appUrl}/verify-email?token=${vToken}`,
|
||||
}),
|
||||
});
|
||||
} catch (mailErr) {
|
||||
console.error('[auth] Échec envoi email bienvenue:', mailErr.message);
|
||||
// Ne pas faire échouer l'inscription si le mail échoue
|
||||
}
|
||||
|
||||
return res.status(201).json({ requiresVerification: true, email: body.email });
|
||||
}
|
||||
|
||||
const token = signToken({ sub: userId, email: body.email });
|
||||
res.status(201).json({
|
||||
token,
|
||||
user: { id: userId, email: body.email, displayName: body.displayName || null, role },
|
||||
user: { id: userId, email: body.email, displayName: body.displayName || null, role, email_verified: 1 },
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/login', (req, res, next) => {
|
||||
const LoginSchema2FA = LoginSchema.extend({
|
||||
deviceToken: z.string().optional(),
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
const body = LoginSchema2FA.parse(req.body);
|
||||
const user = db
|
||||
.prepare('SELECT id, email, password_hash, display_name, role FROM users WHERE email = ?')
|
||||
.prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled FROM users WHERE email = ?')
|
||||
.get(body.email);
|
||||
if (!user) throw new HttpError(401, 'Invalid credentials');
|
||||
|
||||
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
||||
if (!ok) throw new HttpError(401, 'Invalid credentials');
|
||||
|
||||
if (!user.email_verified) {
|
||||
return res.status(403).json({
|
||||
error: 'Veuillez vérifier votre adresse email avant de vous connecter.',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2FA ──────────────────────────────────────────────────────────────
|
||||
if (user.totp_enabled) {
|
||||
// Vérifier si l'appareil est déjà de confiance
|
||||
if (body.deviceToken) {
|
||||
const dev = db.prepare(
|
||||
'SELECT id FROM two_fa_trusted_devices WHERE token = ? AND user_id = ? AND expires_at > datetime(\'now\')'
|
||||
).get(body.deviceToken, user.id);
|
||||
if (dev) {
|
||||
// Appareil de confiance — émettre le JWT directement
|
||||
const token = signToken({ sub: user.id, email: user.email });
|
||||
return res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une session 2FA temporaire (5 minutes)
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO two_fa_sessions (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, sessionToken, expiresAt);
|
||||
|
||||
return res.json({
|
||||
requires2FA: true,
|
||||
sessionToken,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
|
||||
const token = signToken({ sub: user.id, email: user.email });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role },
|
||||
user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 0 },
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = db
|
||||
.prepare('SELECT id, email, display_name, role FROM users WHERE id = ?')
|
||||
.prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id = ?')
|
||||
.get(req.user.id);
|
||||
res.json({ user });
|
||||
});
|
||||
@@ -88,12 +163,12 @@ const UpdateMeSchema = z.object({
|
||||
newPassword: z.string().min(8).optional(),
|
||||
});
|
||||
|
||||
router.put('/me', requireAuth, (req, res, next) => {
|
||||
router.put('/me', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const body = UpdateMeSchema.parse(req.body);
|
||||
|
||||
const current = db
|
||||
.prepare('SELECT id, email, password_hash, display_name FROM users WHERE id = ?')
|
||||
.prepare('SELECT id, email, password_hash, display_name, email_verified FROM users WHERE id = ?')
|
||||
.get(req.user.id);
|
||||
|
||||
let newHash = undefined;
|
||||
@@ -114,18 +189,331 @@ router.put('/me', requireAuth, (req, res, next) => {
|
||||
const newEmail = body.email ?? current.email;
|
||||
const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name;
|
||||
const newPasswordHash = newHash ?? current.password_hash;
|
||||
const emailChanged = newEmail !== current.email;
|
||||
const newEmailVerified = emailChanged ? 0 : (current.email_verified ?? 1);
|
||||
|
||||
db.prepare(
|
||||
"UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?"
|
||||
).run(newEmail, newDisplayName, newPasswordHash, req.user.id);
|
||||
"UPDATE users SET email=?, display_name=?, password_hash=?, email_verified=?, updated_at=datetime('now') WHERE id=?"
|
||||
).run(newEmail, newDisplayName, newPasswordHash, newEmailVerified, req.user.id);
|
||||
|
||||
const token = newEmail !== current.email
|
||||
? signToken({ sub: req.user.id, email: newEmail })
|
||||
: undefined;
|
||||
// Si l'email change, envoyer un email de vérification sur la nouvelle adresse
|
||||
if (emailChanged) {
|
||||
const cfg = getSmtpConfig();
|
||||
if (cfg.enabled && cfg.host) {
|
||||
try {
|
||||
db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(req.user.id);
|
||||
const vToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(req.user.id, vToken, expiresAt);
|
||||
const appUrl = cfg.appUrl?.replace(/\/$/, '') || '';
|
||||
await sendMail({
|
||||
to: newEmail,
|
||||
subject: `Vérifiez votre nouvelle adresse email — ${cfg.appName}`,
|
||||
html: buildEmailHtml({
|
||||
title: 'Vérification de votre nouvel email',
|
||||
body: `<p>Vous avez modifié votre adresse email. Cliquez ci-dessous pour confirmer cette nouvelle adresse.</p>`,
|
||||
ctaLabel: 'Vérifier mon adresse email',
|
||||
ctaUrl: `${appUrl}/verify-email?token=${vToken}`,
|
||||
}),
|
||||
});
|
||||
} catch (_) { /* Ne pas bloquer la réponse si SMTP échoue */ }
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = emailChanged ? signToken({ sub: req.user.id, email: newEmail }) : undefined;
|
||||
const updatedUser = db.prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id=?').get(req.user.id);
|
||||
|
||||
res.json({
|
||||
user: { id: req.user.id, email: newEmail, display_name: newDisplayName },
|
||||
...(token ? { token } : {}),
|
||||
user: updatedUser,
|
||||
...(newToken ? { token: newToken } : {}),
|
||||
...(emailChanged ? { requiresVerification: true } : {}),
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Vérification d'adresse email ──────────────────────────────────────────
|
||||
router.get('/verify-email', (req, res, next) => {
|
||||
try {
|
||||
const token = req.query.token;
|
||||
if (!token) throw new HttpError(400, 'Token manquant.');
|
||||
|
||||
const row = db.prepare(
|
||||
`SELECT evt.*, u.id AS uid FROM email_verification_tokens evt
|
||||
JOIN users u ON u.id = evt.user_id
|
||||
WHERE evt.token = ? AND evt.used = 0`
|
||||
).get(token);
|
||||
|
||||
if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.');
|
||||
if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Demandez un nouvel email de vérification.');
|
||||
|
||||
db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(row.uid);
|
||||
db.prepare('UPDATE email_verification_tokens SET used=1 WHERE id=?').run(row.id);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Renvoi de l'email de vérification ─────────────────────────────────────
|
||||
router.post('/resend-verification', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email() }).parse(req.body);
|
||||
const user = db.prepare('SELECT id, email, display_name, email_verified FROM users WHERE email=?').get(email);
|
||||
|
||||
// Répondre OK même si l'email n'existe pas
|
||||
if (!user || user.email_verified) return res.json({ ok: true });
|
||||
|
||||
const cfg = getSmtpConfig();
|
||||
if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer l'email.");
|
||||
|
||||
// Invalider les anciens tokens
|
||||
db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(user.id);
|
||||
|
||||
const vToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, vToken, expiresAt);
|
||||
|
||||
const appUrl = cfg.appUrl?.replace(/\/$/, '') || '';
|
||||
const prenom = (user.display_name || user.email).split(' ')[0];
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Vérifiez votre adresse email — ${cfg.appName}`,
|
||||
html: buildEmailHtml({
|
||||
title: 'Confirmez votre adresse email',
|
||||
body: `<p>Bonjour ${prenom},</p>
|
||||
<p>Cliquez sur le bouton ci-dessous pour vérifier votre adresse email. Ce lien est valable <strong>24 heures</strong>.</p>`,
|
||||
ctaLabel: 'Vérifier mon adresse email',
|
||||
ctaUrl: `${appUrl}/verify-email?token=${vToken}`,
|
||||
}),
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Mot de passe oublié ───────────────────────────────────────────────────
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email() }).parse(req.body);
|
||||
const user = db.prepare('SELECT id, email, display_name FROM users WHERE email = ?').get(email);
|
||||
|
||||
// Toujours répondre OK pour ne pas divulguer si l'email existe
|
||||
if (!user) return res.json({ ok: true });
|
||||
|
||||
const cfg = getSmtpConfig();
|
||||
if (!cfg.enabled) throw new HttpError(503, 'La réinitialisation par email n\'est pas disponible (SMTP désactivé).');
|
||||
|
||||
// Invalider les anciens tokens non utilisés
|
||||
db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ? AND used = 0').run(user.id);
|
||||
|
||||
// Générer un token sécurisé (1h de validité)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)').run(user.id, token, expiresAt);
|
||||
|
||||
const appUrl = cfg.appUrl?.replace(/\/$/, '') || '';
|
||||
const resetUrl = `${appUrl}/reset-password?token=${token}`;
|
||||
const prenom = (user.display_name || user.email).split(' ')[0];
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Réinitialisation de votre mot de passe — ${cfg.appName}`,
|
||||
html: buildEmailHtml({
|
||||
title: 'Réinitialisation du mot de passe',
|
||||
body: `<p>Bonjour ${prenom},</p>
|
||||
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en définir un nouveau. Ce lien est valable <strong>1 heure</strong>.</p>
|
||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email.</p>`,
|
||||
ctaLabel: 'Réinitialiser mon mot de passe',
|
||||
ctaUrl: resetUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Réinitialisation du mot de passe ──────────────────────────────────────
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8),
|
||||
}).parse(req.body);
|
||||
|
||||
const row = db.prepare(
|
||||
`SELECT prt.*, u.id AS uid FROM password_reset_tokens prt
|
||||
JOIN users u ON u.id = prt.user_id
|
||||
WHERE prt.token = ? AND prt.used = 0`
|
||||
).get(token);
|
||||
|
||||
if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.');
|
||||
if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Faites une nouvelle demande.');
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, row.uid);
|
||||
db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE id = ?').run(row.id);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ── 2FA — Configuration TOTP ─────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// GET /2fa/setup — génère un secret TOTP + QR code pour l'utilisateur
|
||||
router.get('/2fa/setup', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { authenticator } = await import('otplib');
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
|
||||
const cfg = getSmtpConfig();
|
||||
const issuer = cfg.appName || 'Crowdlending Tracker';
|
||||
const user = db.prepare('SELECT id, email, totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id);
|
||||
|
||||
// Générer un nouveau secret (ou réutiliser si setup pas encore confirmé)
|
||||
const secret = (user.totp_enabled ? null : user.totp_secret) || authenticator.generateSecret();
|
||||
|
||||
if (!user.totp_enabled) {
|
||||
db.prepare("UPDATE users SET totp_secret=? WHERE id=?").run(secret, req.user.id);
|
||||
}
|
||||
|
||||
const uri = authenticator.keyuri(user.email, issuer, secret);
|
||||
const qrCode = await QRCode.toDataURL(uri);
|
||||
|
||||
res.json({ secret, qrCode, issuer, email: user.email, totp_enabled: !!user.totp_enabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /2fa/confirm-setup — vérifie le code TOTP et active le 2FA
|
||||
router.post('/2fa/confirm-setup', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = z.object({ code: z.string().length(6) }).parse(req.body);
|
||||
const { authenticator } = await import('otplib');
|
||||
|
||||
const user = db.prepare('SELECT totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id);
|
||||
if (!user.totp_secret) throw new HttpError(400, 'Lancez d\'abord la configuration 2FA.');
|
||||
if (user.totp_enabled) throw new HttpError(400, 'Le 2FA est déjà activé.');
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
const valid = authenticator.verify({ token: code, secret: user.totp_secret });
|
||||
if (!valid) throw new HttpError(400, 'Code invalide. Réessayez.');
|
||||
|
||||
db.prepare("UPDATE users SET totp_enabled=1 WHERE id=?").run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /2fa/disable — désactive le 2FA (mot de passe requis)
|
||||
router.post('/2fa/disable', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { password } = z.object({ password: z.string().min(1) }).parse(req.body);
|
||||
|
||||
const user = db.prepare('SELECT password_hash, totp_enabled FROM users WHERE id=?').get(req.user.id);
|
||||
if (!user.totp_enabled) throw new HttpError(400, 'Le 2FA n\'est pas activé.');
|
||||
|
||||
const ok = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!ok) throw new HttpError(401, 'Mot de passe incorrect.');
|
||||
|
||||
db.prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=?").run(req.user.id);
|
||||
// Supprimer tous les appareils de confiance
|
||||
db.prepare('DELETE FROM two_fa_trusted_devices WHERE user_id=?').run(req.user.id);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ── 2FA — Vérification lors de la connexion ──────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /2fa/send-email-code — envoie un OTP par email (flow 2FA)
|
||||
router.post('/2fa/send-email-code', async (req, res, next) => {
|
||||
try {
|
||||
const { sessionToken } = z.object({ sessionToken: z.string().min(1) }).parse(req.body);
|
||||
|
||||
const sess = db.prepare(
|
||||
"SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')"
|
||||
).get(sessionToken);
|
||||
if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.');
|
||||
|
||||
const user = db.prepare('SELECT id, email, display_name FROM users WHERE id=?').get(sess.user_id);
|
||||
|
||||
// Invalider les anciens codes
|
||||
db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE user_id=? AND used=0').run(user.id);
|
||||
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO two_fa_email_codes (user_id, code, expires_at) VALUES (?,?,?)').run(user.id, code, expiresAt);
|
||||
|
||||
const cfg = getSmtpConfig();
|
||||
if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer le code.");
|
||||
|
||||
const prenom = (user.display_name || user.email).split(' ')[0];
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `${code} — Votre code de connexion`,
|
||||
html: buildEmailHtml({
|
||||
title: 'Code de vérification',
|
||||
body: `<p>Bonjour ${prenom},</p>
|
||||
<p>Voici votre code de vérification à usage unique :</p>
|
||||
<p style="font-size:32px;font-weight:700;letter-spacing:8px;text-align:center;margin:24px 0;">${code}</p>
|
||||
<p>Ce code est valable <strong>5 minutes</strong>. Ne le communiquez à personne.</p>`,
|
||||
}),
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /2fa/verify — vérifie le code (TOTP ou email) et émet le JWT final
|
||||
router.post('/2fa/verify', async (req, res, next) => {
|
||||
try {
|
||||
const { sessionToken, code, method, trustDevice } = z.object({
|
||||
sessionToken: z.string().min(1),
|
||||
code: z.string().min(6).max(6),
|
||||
method: z.enum(['totp', 'email']),
|
||||
trustDevice: z.boolean().optional(),
|
||||
}).parse(req.body);
|
||||
|
||||
const sess = db.prepare(
|
||||
"SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')"
|
||||
).get(sessionToken);
|
||||
if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.');
|
||||
|
||||
const user = db.prepare('SELECT id, email, display_name, role, totp_secret, totp_enabled FROM users WHERE id=?').get(sess.user_id);
|
||||
|
||||
if (method === 'totp') {
|
||||
const { authenticator } = await import('otplib');
|
||||
authenticator.options = { window: 1 };
|
||||
const valid = authenticator.verify({ token: code, secret: user.totp_secret });
|
||||
if (!valid) throw new HttpError(400, 'Code invalide.');
|
||||
} else {
|
||||
// Email OTP
|
||||
const row = db.prepare(
|
||||
"SELECT id FROM two_fa_email_codes WHERE user_id=? AND code=? AND used=0 AND expires_at > datetime('now')"
|
||||
).get(user.id, code);
|
||||
if (!row) throw new HttpError(400, 'Code invalide ou expiré.');
|
||||
db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE id=?').run(row.id);
|
||||
}
|
||||
|
||||
// Invalider la session 2FA
|
||||
db.prepare('UPDATE two_fa_sessions SET used=1 WHERE id=?').run(sess.id);
|
||||
|
||||
// Appareil de confiance (30 jours)
|
||||
let deviceToken = null;
|
||||
if (trustDevice) {
|
||||
deviceToken = crypto.randomBytes(32).toString('hex');
|
||||
const devExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO two_fa_trusted_devices (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, deviceToken, devExpires);
|
||||
}
|
||||
|
||||
const token = signToken({ sub: user.id, email: user.email });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 },
|
||||
...(deviceToken ? { deviceToken } : {}),
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ import refSecteursRouter from './routes/ref-secteurs.js';
|
||||
import categoriesInvRouter from './routes/categories-inv.js';
|
||||
import secteursInvRouter from './routes/secteurs-inv.js';
|
||||
import associationsInvRouter from './routes/associations-inv.js';
|
||||
import db from './db/index.js';
|
||||
import { getSmtpConfig } from './utils/mailer.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
@@ -69,6 +71,20 @@ const authLimiter = rateLimit({
|
||||
|
||||
app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOString() }));
|
||||
|
||||
// Informations publiques de la plateforme (utilisées sur la page de login)
|
||||
app.get('/api/app-info', (_, res) => {
|
||||
try {
|
||||
const cfg = getSmtpConfig();
|
||||
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
|
||||
res.json({
|
||||
appName: cfg.appName || 'Crowdlending Tracker',
|
||||
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
|
||||
});
|
||||
} catch {
|
||||
res.json({ appName: 'Crowdlending Tracker', iconUrl: null });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/auth', authLimiter, authRouter);
|
||||
|
||||
// All routes below require authentication
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getSmtpConfig() {
|
||||
username: row?.username || process.env.SMTP_USERNAME || '',
|
||||
password: row?.password || process.env.SMTP_PASSWORD || '',
|
||||
allowUnauth: !!(row?.allow_unauth),
|
||||
appName: row?.app_name || process.env.APP_NAME || 'Crowdlending',
|
||||
appName: row?.app_name || process.env.APP_NAME || 'Crowdlending Tracker',
|
||||
appUrl: row?.app_url || process.env.APP_URL || '',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user