From 402d690e964fbe647bb6d1fb385ea6e507da673c Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 14 Jun 2026 22:13:33 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=202FA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 14 +++ backend/src/routes/auth.js | 49 ++++++++- frontend/src/api.js | 6 +- frontend/src/pages/MonCompte.jsx | 164 ++++++++++++++++++++++++++++++- 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index f6728ef..a5f734b 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1775,6 +1775,20 @@ db.exec(` 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)'); + +// ── Migration : user_agent + ip_address sur two_fa_trusted_devices ─────────── +{ + const devCols = db.prepare('PRAGMA table_info(two_fa_trusted_devices)').all().map(c => c.name); + if (!devCols.includes('user_agent')) { + db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN user_agent TEXT'); + console.log('[DB] two_fa_trusted_devices.user_agent ajouté'); + } + if (!devCols.includes('ip_address')) { + db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN ip_address TEXT'); + console.log('[DB] two_fa_trusted_devices.ip_address ajouté'); + } +} + console.log('[DB] Migrations 2FA OK'); export default db; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 45d5a65..7e59753 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -501,7 +501,9 @@ router.post('/2fa/verify', async (req, res, next) => { 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 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); } const token = signToken({ sub: user.id, email: user.email }); @@ -513,4 +515,49 @@ router.post('/2fa/verify', async (req, res, next) => { } 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 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, + })); + + 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); + 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; diff --git a/frontend/src/api.js b/frontend/src/api.js index c2a716b..c4c42e9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -5,9 +5,11 @@ const BASE = import.meta.env.VITE_API_URL || '/api'; function authHeaders() { const token = localStorage.getItem('cl_token'); const investisseurId = localStorage.getItem('cl_investisseur_id'); + const deviceToken = localStorage.getItem('cl_device_token'); const h = {}; if (token) h['Authorization'] = `Bearer ${token}`; if (investisseurId) h['X-Investisseur-Id'] = investisseurId; + if (deviceToken) h['X-Device-Token'] = deviceToken; return h; } @@ -63,6 +65,4 @@ export const api = { }), exportUrl: (path, params) => { const qs = params ? '?' + new URLSearchParams(params).toString() : ''; - return BASE + path + qs; - }, -}; + \ No newline at end of file diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index 317e6db..1f44368 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -488,6 +488,168 @@ function SecurityForm() { ); } + +/* ── Appareils connectés ─────────────────────────────────────── */ +function parseUA(ua) { + if (!ua) return { device: 'Inconnu', browser: '', icon: 'desktop' }; + const isMobile = /mobile|android|iphone|ipad/i.test(ua); + let browser = 'Navigateur inconnu'; + if (/Edg\//i.test(ua)) browser = 'Edge ' + (ua.match(/Edg\/([\d.]+)/)?.[1]?.split('.')[0] || ''); + else if (/Chrome\//i.test(ua)) browser = 'Chrome ' + (ua.match(/Chrome\/([\d.]+)/)?.[1]?.split('.')[0] || ''); + else if (/Firefox\//i.test(ua)) browser = 'Firefox ' + (ua.match(/Firefox\/([\d.]+)/)?.[1]?.split('.')[0] || ''); + else if (/Safari\//i.test(ua)) browser = 'Safari ' + (ua.match(/Version\/([\d.]+)/)?.[1]?.split('.')[0] || ''); + let os = 'Inconnu'; + if (/Windows/i.test(ua)) os = 'Windows'; + else if (/Macintosh/i.test(ua)) os = 'Mac'; + else if (/Linux/i.test(ua)) os = 'Linux'; + else if (/Android/i.test(ua)) os = 'Android'; + else if (/iPhone|iPad/i.test(ua)) os = 'iOS'; + return { device: os + (browser ? ' — ' + browser : ''), icon: isMobile ? 'mobile' : 'desktop' }; +} + +function DeviceIcon({ type }) { + if (type === 'mobile') return ( + + + + + ); + return ( + + + + + ); +} + +function TrustedDevicesSection() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); // id en cours de révocation + + const currentToken = localStorage.getItem('cl_device_token'); + + const load = async () => { + try { + setLoading(true); + const data = await api.get('/auth/trusted-devices'); + setDevices(data); + } catch (e) { setErr(e.message); } + finally { setLoading(false); } + }; + + useEffect(() => { load(); }, []); + + const revoke = async (id, isCurrent) => { + setBusy(id); + try { + await api.del(`/auth/trusted-devices/${id}`); + if (isCurrent) localStorage.removeItem('cl_device_token'); + await load(); + } catch (e) { setErr(e.message); } + finally { setBusy(null); } + }; + + const revokeAll = async () => { + setBusy('all'); + try { + await api.del('/auth/trusted-devices'); + localStorage.removeItem('cl_device_token'); + await load(); + } catch (e) { setErr(e.message); } + finally { setBusy(null); } + }; + + if (loading) return null; + + return ( +
+
+

Appareils de confiance

+ {devices.length > 0 && ( + + )} +
+

+ Appareils reconnus pour la double authentification (validité 30 jours). +

+ + {err &&
{err}
} + + {devices.length === 0 ? ( +

Aucun appareil de confiance enregistré.

+ ) : ( +
+ {devices.map(dev => { + const { device, icon } = parseUA(dev.user_agent); + const connDate = dev.created_at + ? new Date(dev.created_at).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' — ' + + new Date(dev.created_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + : '—'; + return ( +
+
+ +
+
+
+ {device} + {dev.isCurrent && ( + APPAREIL ACTUEL + )} +
+
+ {dev.ip_address && {dev.ip_address} · } + Connecté le {connDate} +
+
+ +
+ ); + })} +
+ )} +
+ ); +} + /* ── Page principale ─────────────────────────────────────────── */ export default function MonCompte() { const { search } = useLocation(); @@ -522,7 +684,7 @@ export default function MonCompte() { {/* ── Contenu ─────────────────────────────────────── */}
{section === 'profil' && } - {section === 'securite' && <>} + {section === 'securite' && <>}