681 lines
31 KiB
JavaScript
681 lines
31 KiB
JavaScript
import { Router } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'node:crypto';
|
|
import { z } from 'zod';
|
|
import db, { grantDefaultWorkspace } 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';
|
|
import { createRequire } from 'node:module';
|
|
const _require = createRequire(import.meta.url);
|
|
const QRCode = _require('qrcode');
|
|
import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib';
|
|
import { audit } from '../utils/audit.js';
|
|
import { deleteAllDocumentFilesForUser } from './documents.js';
|
|
|
|
const router = Router();
|
|
|
|
const RegisterSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
displayName: z.string().min(1).optional(),
|
|
});
|
|
|
|
const LoginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
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);
|
|
if (exists) throw new HttpError(409, 'Email already registered');
|
|
|
|
// Le premier utilisateur inscrit devient automatiquement administrateur
|
|
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, email_verified) VALUES (?, ?, ?, ?, ?)')
|
|
.run(body.email, hash, body.displayName || null, role, autoVerified);
|
|
|
|
const userId = result.lastInsertRowid;
|
|
|
|
// Auto-create le premier profil famille (= l'utilisateur lui-même)
|
|
const fullName = body.displayName || 'Mon profil';
|
|
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
|
|
const invResult = db.prepare(
|
|
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
|
|
).run(userId, fullName, prenom);
|
|
|
|
// Auto-créer un compte courant pour le profil principal
|
|
db.prepare(
|
|
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
|
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
|
|
|
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
|
|
// — cf. project_workspaces_transformation.md.
|
|
grantDefaultWorkspace(userId);
|
|
|
|
// 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
|
|
}
|
|
|
|
audit(req, { action: 'user_registered', category: 'account', targetUserId: userId, details: { email: body.email, role, auto_verified: false } });
|
|
return res.status(201).json({ requiresVerification: true, email: body.email });
|
|
}
|
|
|
|
audit(req, { action: 'user_registered', category: 'account', targetUserId: userId, details: { email: body.email, role, auto_verified: true } });
|
|
const token = signToken({ sub: userId, email: body.email });
|
|
res.status(201).json({
|
|
token,
|
|
user: { id: userId, email: body.email, displayName: body.displayName || null, role, email_verified: 1 },
|
|
});
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
const LoginSchema2FA = LoginSchema.extend({
|
|
deviceToken: z.string().optional(),
|
|
});
|
|
|
|
router.post('/login', async (req, res, next) => {
|
|
try {
|
|
const body = LoginSchema2FA.parse(req.body);
|
|
const user = db
|
|
.prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled, status FROM users WHERE email = ?')
|
|
.get(body.email);
|
|
if (!user) {
|
|
audit(req, { action: 'login_failed', category: 'auth', details: { email: body.email, reason: 'user_not_found' } });
|
|
throw new HttpError(401, 'Invalid credentials');
|
|
}
|
|
|
|
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
|
if (!ok) {
|
|
audit(req, { action: 'login_failed', category: 'auth', targetUserId: user.id, details: { email: body.email, reason: 'wrong_password' } });
|
|
throw new HttpError(401, 'Invalid credentials');
|
|
}
|
|
|
|
if (user.status === 'deactivated') {
|
|
return res.status(403).json({ error: 'Ce compte a été désactivé. Contactez un administrateur.', code: 'ACCOUNT_DEACTIVATED' });
|
|
}
|
|
if (user.status === 'locked') {
|
|
return res.status(403).json({ error: 'Ce compte est verrouillé. Contactez un administrateur.', code: 'ACCOUNT_LOCKED' });
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
audit(req, { action: 'login_success', category: 'auth', actorId: user.id, targetUserId: user.id, details: { 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, 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, email_verified, totp_enabled,
|
|
EXISTS(
|
|
SELECT 1 FROM frais_operations f
|
|
JOIN investissements i ON i.id = f.investissement_id
|
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
|
WHERE inv.user_id = users.id
|
|
) AS has_frais,
|
|
EXISTS(
|
|
SELECT 1 FROM frais_operations f
|
|
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
|
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
|
WHERE inv.user_id = users.id
|
|
) AS has_frais_pe
|
|
FROM users WHERE id = ?
|
|
`)
|
|
.get(req.user.id);
|
|
res.json({ user });
|
|
});
|
|
|
|
const UpdateMeSchema = z.object({
|
|
displayName: z.string().min(1).max(80).optional(),
|
|
email: z.string().email().optional(),
|
|
currentPassword: z.string().optional(),
|
|
newPassword: z.string().min(8).optional(),
|
|
});
|
|
|
|
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, email_verified FROM users WHERE id = ?')
|
|
.get(req.user.id);
|
|
|
|
let newHash = undefined;
|
|
|
|
if (body.newPassword) {
|
|
if (!body.currentPassword)
|
|
throw new HttpError(400, 'Mot de passe actuel requis');
|
|
const ok = bcrypt.compareSync(body.currentPassword, current.password_hash);
|
|
if (!ok) throw new HttpError(401, 'Mot de passe actuel incorrect');
|
|
newHash = bcrypt.hashSync(body.newPassword, 10);
|
|
}
|
|
|
|
if (body.email && body.email !== current.email) {
|
|
const taken = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(body.email, req.user.id);
|
|
if (taken) throw new HttpError(409, 'Email déjà utilisé par un autre compte');
|
|
}
|
|
|
|
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=?, email_verified=?, updated_at=datetime('now') WHERE id=?"
|
|
).run(newEmail, newDisplayName, newPasswordHash, newEmailVerified, req.user.id);
|
|
|
|
// 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: updatedUser,
|
|
...(newToken ? { token: newToken } : {}),
|
|
...(emailChanged ? { requiresVerification: true } : {}),
|
|
});
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Suppression définitive du compte (self-service) ────────────────────────
|
|
const DeleteMeSchema = z.object({
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
router.delete('/me', requireAuth, (req, res, next) => {
|
|
try {
|
|
const { password } = DeleteMeSchema.parse(req.body);
|
|
|
|
const user = db
|
|
.prepare('SELECT id, email, display_name, role, password_hash FROM users WHERE id = ?')
|
|
.get(req.user.id);
|
|
if (!user) throw new HttpError(404, 'Utilisateur introuvable');
|
|
|
|
const ok = bcrypt.compareSync(password, user.password_hash);
|
|
if (!ok) throw new HttpError(401, 'Mot de passe incorrect.');
|
|
|
|
// Empêche de se retrouver sans aucun administrateur sur l'application
|
|
if (user.role === 'admin') {
|
|
const { n: adminCount } = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get();
|
|
if (adminCount <= 1) {
|
|
throw new HttpError(400, "Vous êtes le seul administrateur de l'application. Promouvez un autre compte en administrateur avant de supprimer le vôtre.");
|
|
}
|
|
}
|
|
|
|
// Log AVANT suppression : target_user_id/actor_id passeront à NULL après le DELETE
|
|
// (FK ON DELETE SET NULL), mais les informations restent lisibles dans "details".
|
|
audit(req, {
|
|
action: 'account_self_deleted',
|
|
category: 'account',
|
|
actorId: user.id,
|
|
targetUserId: user.id,
|
|
details: {
|
|
email: user.email,
|
|
display_name: user.display_name,
|
|
role: user.role,
|
|
initiated_by: 'self',
|
|
note: "Suppression de compte initiée par l'utilisateur lui-même depuis Mon compte.",
|
|
},
|
|
});
|
|
|
|
// Notifier les autres administrateurs
|
|
const otherAdmins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND id != ?").all(user.id);
|
|
if (otherAdmins.length > 0) {
|
|
const insertNotif = db.prepare(
|
|
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)'
|
|
);
|
|
const notifyTx = db.transaction((rows) => {
|
|
for (const admin of rows) {
|
|
insertNotif.run(
|
|
admin.id,
|
|
'security',
|
|
'Suppression de compte utilisateur',
|
|
`${user.display_name || user.email} (${user.email}) a supprimé définitivement son propre compte.`,
|
|
'/admin?section=audit-logs',
|
|
);
|
|
}
|
|
});
|
|
notifyTx(otherAdmins);
|
|
}
|
|
|
|
// Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users
|
|
// (les lignes `documents` seront supprimées en cascade par la contrainte
|
|
// ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers
|
|
// du disque — il faut les lister pendant que le user existe encore).
|
|
deleteAllDocumentFilesForUser(user.id);
|
|
|
|
// Suppression définitive — cascade en base sur toutes les données liées
|
|
// (investisseurs, plateformes, investissements, remboursements, comptes,
|
|
// préférences, notifications, tickets, appareils de confiance, etc.)
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
|
|
|
res.status(204).end();
|
|
} 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 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) || totpGenerateSecret();
|
|
|
|
if (!user.totp_enabled) {
|
|
db.prepare("UPDATE users SET totp_secret=? WHERE id=?").run(secret, req.user.id);
|
|
}
|
|
|
|
const uri = totpGenerateURI({ issuer, label: user.email, 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 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é.');
|
|
const valid = totpVerifySync({ token: code, secret: user.totp_secret, strategy: "totp" });
|
|
if (!valid) throw new HttpError(400, 'Code invalide. Réessayez.');
|
|
|
|
db.prepare("UPDATE users SET totp_enabled=1 WHERE id=?").run(req.user.id);
|
|
audit(req, { action: '2fa_enabled', category: '2fa', actorId: req.user.id, targetUserId: 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);
|
|
audit(req, { action: '2fa_disabled', category: '2fa', actorId: req.user.id, targetUserId: 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 valid = totpVerifySync({ token: code, secret: user.totp_secret, strategy: "totp" });
|
|
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();
|
|
const ua = req.headers['user-agent'] || null;
|
|
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || null;
|
|
db.prepare('INSERT INTO two_fa_trusted_devices (user_id, token, expires_at, user_agent, ip_address) VALUES (?,?,?,?,?)').run(user.id, deviceToken, devExpires, ua, ip);
|
|
}
|
|
|
|
audit(req, { action: 'login_2fa_success', category: 'auth', actorId: user.id, targetUserId: user.id, details: { method, trustDevice: !!trustDevice } });
|
|
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); }
|
|
});
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// ── Appareils de confiance ───────────────────────────────────────────────
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
// GET /auth/trusted-devices — liste les appareils de confiance de l'utilisateur
|
|
router.get('/trusted-devices', requireAuth, (req, res, next) => {
|
|
try {
|
|
const currentToken = req.headers['x-device-token'] || null;
|
|
const devices = db.prepare(
|
|
"SELECT id, token, user_agent, ip_address, created_at, expires_at, last_seen_at FROM two_fa_trusted_devices WHERE user_id=? AND expires_at > datetime('now') ORDER BY created_at DESC"
|
|
).all(req.user.id);
|
|
|
|
const result = devices.map(d => ({
|
|
id: d.id,
|
|
isCurrent: !!(currentToken && d.token === currentToken),
|
|
user_agent: d.user_agent,
|
|
ip_address: d.ip_address,
|
|
created_at: d.created_at,
|
|
expires_at: d.expires_at,
|
|
last_seen_at: d.last_seen_at,
|
|
}));
|
|
|
|
res.json(result);
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// DELETE /auth/trusted-devices/:id — révoquer un appareil
|
|
router.delete('/trusted-devices/:id', requireAuth, (req, res, next) => {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
const dev = db.prepare('SELECT id FROM two_fa_trusted_devices WHERE id=? AND user_id=?').get(id, req.user.id);
|
|
if (!dev) throw new HttpError(404, 'Appareil introuvable.');
|
|
db.prepare('DELETE FROM two_fa_trusted_devices WHERE id=?').run(id); db.prepare('DELETE FROM two_fa_trusted_devices WHERE id=?').run(id);
|
|
res.json({ ok: true });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// DELETE /auth/trusted-devices — révoquer tous les appareils
|
|
router.delete('/trusted-devices', requireAuth, (req, res, next) => {
|
|
try {
|
|
db.prepare('DELETE FROM two_fa_trusted_devices WHERE user_id=?').run(req.user.id);
|
|
res.json({ ok: true });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
export default router;
|