jeremy пре 4 месеци
родитељ
комит
91ccecafe3

+ 9 - 0
.env.example

@@ -4,3 +4,12 @@ PGUSER=postgres
 PGPASSWORD=postgres
 PGDATABASE=gestion_locative
 JWT_SECRET=change_this_secret_in_production
+
+# SSO OIDC (optionnel — laisser vide pour désactiver le bouton SSO)
+OIDC_ISSUER=https://your-provider.example.com/realms/myrealm
+OIDC_CLIENT_ID=gestion-locative
+OIDC_CLIENT_SECRET=your-client-secret
+OIDC_REDIRECT_URI=http://localhost:3001/api/auth/oidc/callback
+OIDC_SCOPE=openid email profile
+OIDC_BUTTON_LABEL=Se connecter via SSO
+FRONTEND_URL=http://localhost:3000

+ 1 - 0
backend/package.json

@@ -9,6 +9,7 @@
   "dependencies": {
     "bcryptjs": "^2.4.3",
     "pg": "^8.11.3",
+    "openid-client": "^5.6.4",
     "cors": "^2.8.5",
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",

+ 4 - 1
backend/src/db.js

@@ -13,12 +13,13 @@ async function initDb() {
     CREATE TABLE IF NOT EXISTS users (
       id SERIAL PRIMARY KEY,
       email TEXT UNIQUE NOT NULL,
-      password TEXT NOT NULL,
+      password TEXT,
       name TEXT NOT NULL,
       first_name TEXT,
       last_name TEXT,
       address TEXT,
       phone TEXT,
+      oidc_sub TEXT UNIQUE,
       created_at TIMESTAMP DEFAULT NOW()
     );
 
@@ -141,6 +142,8 @@ async function initDb() {
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT`);
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS address TEXT`);
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT`);
+  await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT UNIQUE`);
+  await pool.query(`ALTER TABLE users ALTER COLUMN password DROP NOT NULL`);
   await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS owner_id INTEGER REFERENCES owners(id)`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable INTEGER NOT NULL DEFAULT 1`);
   await pool.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS charge_regularization REAL DEFAULT 0`);

+ 123 - 0
backend/src/routes/auth.js

@@ -1,11 +1,134 @@
 const express = require('express');
 const bcrypt = require('bcryptjs');
 const jwt = require('jsonwebtoken');
+const { Issuer } = require('openid-client');
 const pool = require('../db');
 const { JWT_SECRET } = require('../auth');
 
 const router = express.Router();
 
+// ─── OIDC client (lazy-initialized) ────────────────────────────────────────
+
+let oidcClient = null;
+
+async function getOidcClient() {
+  if (oidcClient) return oidcClient;
+  const issuerUrl = process.env.OIDC_ISSUER;
+  if (!issuerUrl) return null;
+  try {
+    const issuer = await Issuer.discover(issuerUrl);
+    oidcClient = new issuer.Client({
+      client_id: process.env.OIDC_CLIENT_ID,
+      client_secret: process.env.OIDC_CLIENT_SECRET,
+      redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3001/api/auth/oidc/callback'],
+      response_types: ['code'],
+    });
+    return oidcClient;
+  } catch (e) {
+    console.error('OIDC discovery failed:', e.message);
+    return null;
+  }
+}
+
+// State store simple en mémoire (suffisant pour usage mono-instance)
+const stateStore = new Map();
+
+// ─── OIDC config endpoint (pour le frontend) ────────────────────────────────
+
+router.get('/oidc/config', (req, res) => {
+  res.json({
+    enabled: !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID),
+    label: process.env.OIDC_BUTTON_LABEL || 'Se connecter via SSO',
+  });
+});
+
+// ─── OIDC: initier le flux ──────────────────────────────────────────────────
+
+router.get('/oidc/login', async (req, res) => {
+  const client = await getOidcClient();
+  if (!client) return res.status(503).json({ error: 'SSO non configuré' });
+
+  const { generators } = require('openid-client');
+  const state = generators.state();
+  const nonce = generators.nonce();
+
+  stateStore.set(state, { nonce, createdAt: Date.now() });
+  // Nettoyer les états > 10 min
+  for (const [k, v] of stateStore) {
+    if (Date.now() - v.createdAt > 600_000) stateStore.delete(k);
+  }
+
+  const redirectUrl = client.authorizationUrl({
+    scope: process.env.OIDC_SCOPE || 'openid email profile',
+    state,
+    nonce,
+  });
+
+  res.redirect(redirectUrl);
+});
+
+// ─── OIDC: callback ─────────────────────────────────────────────────────────
+
+router.get('/oidc/callback', async (req, res) => {
+  const client = await getOidcClient();
+  if (!client) return res.status(503).json({ error: 'SSO non configuré' });
+
+  const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
+
+  try {
+    const { generators } = require('openid-client');
+    const params = client.callbackParams(req);
+    const stored = stateStore.get(params.state);
+    if (!stored) return res.redirect(`${frontendUrl}/login?error=invalid_state`);
+    stateStore.delete(params.state);
+
+    const tokenSet = await client.callback(
+      process.env.OIDC_REDIRECT_URI || 'http://localhost:3001/api/auth/oidc/callback',
+      params,
+      { state: params.state, nonce: stored.nonce }
+    );
+
+    const claims = tokenSet.claims();
+    const sub = claims.sub;
+    const email = claims.email;
+    const name = claims.name || claims.preferred_username || email;
+    const firstName = claims.given_name || null;
+    const lastName = claims.family_name || null;
+
+    if (!sub) return res.redirect(`${frontendUrl}/login?error=no_sub`);
+
+    // Trouver ou créer l'utilisateur
+    let user = (await pool.query('SELECT * FROM users WHERE oidc_sub = $1', [sub])).rows[0];
+
+    if (!user && email) {
+      // Tenter de lier à un compte local existant avec le même email
+      user = (await pool.query('SELECT * FROM users WHERE email = $1', [email])).rows[0];
+      if (user) {
+        await pool.query('UPDATE users SET oidc_sub = $1 WHERE id = $2', [sub, user.id]);
+      }
+    }
+
+    if (!user) {
+      // Créer un nouveau compte OIDC
+      const result = await pool.query(
+        'INSERT INTO users (email, name, first_name, last_name, oidc_sub) VALUES ($1, $2, $3, $4, $5) RETURNING *',
+        [email || `oidc_${sub}@sso`, name, firstName, lastName, sub]
+      );
+      user = result.rows[0];
+    }
+
+    const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
+
+    // Rediriger vers le frontend avec le token dans le hash (évite les logs serveur)
+    res.redirect(`${frontendUrl}/auth/callback#token=${token}`);
+  } catch (err) {
+    console.error('OIDC callback error:', err.message);
+    res.redirect(`${(process.env.FRONTEND_URL || 'http://localhost:3000')}/login?error=oidc_failed`);
+  }
+});
+
+// ─── Auth locale ─────────────────────────────────────────────────────────────
+
 router.post('/register', async (req, res) => {
   const { email, password, name } = req.body;
   if (!email || !password || !name)

+ 8 - 0
docker-compose.yml

@@ -29,6 +29,14 @@ services:
       - PGUSER=postgres
       - PGPASSWORD=postgres
       - PGDATABASE=gestion_locative
+      # SSO OIDC (optionnel — laisser vide pour désactiver)
+      - OIDC_ISSUER=${OIDC_ISSUER:-}
+      - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
+      - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
+      - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-http://localhost:3001/api/auth/oidc/callback}
+      - OIDC_SCOPE=${OIDC_SCOPE:-openid email profile}
+      - OIDC_BUTTON_LABEL=${OIDC_BUTTON_LABEL:-Se connecter via SSO}
+      - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
     depends_on:
       - postgres
     restart: unless-stopped

+ 2 - 0
frontend/src/App.jsx

@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'
 import Layout from './components/Layout'
 import Login from './pages/Login'
 import Register from './pages/Register'
+import OAuthCallback from './pages/OAuthCallback'
 import Dashboard from './pages/Dashboard'
 import Properties from './pages/Properties'
 import Tenants from './pages/Tenants'
@@ -34,6 +35,7 @@ export default function App() {
         <Routes>
           <Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
           <Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
+          <Route path="/auth/callback" element={<OAuthCallback />} />
           <Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
             <Route index element={<Dashboard />} />
             <Route path="properties" element={<Properties />} />

+ 25 - 1
frontend/src/pages/Login.jsx

@@ -1,14 +1,20 @@
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
 import { Link, useNavigate } from 'react-router-dom'
 import { useAuth } from '../context/AuthContext'
+import api from '../api'
 import toast from 'react-hot-toast'
 
 export default function Login() {
   const [form, setForm] = useState({ email: '', password: '' })
   const [loading, setLoading] = useState(false)
+  const [oidcConfig, setOidcConfig] = useState({ enabled: false, label: 'Se connecter via SSO' })
   const { login } = useAuth()
   const navigate = useNavigate()
 
+  useEffect(() => {
+    api.get('/auth/oidc/config').then(res => setOidcConfig(res.data)).catch(() => {})
+  }, [])
+
   const handleSubmit = async e => {
     e.preventDefault()
     setLoading(true)
@@ -22,6 +28,10 @@ export default function Login() {
     }
   }
 
+  const handleSso = () => {
+    window.location.href = '/api/auth/oidc/login'
+  }
+
   return (
     <div className="min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center p-4">
       <div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
@@ -30,6 +40,20 @@ export default function Login() {
           <h1 className="text-2xl font-bold text-gray-900">Gestion Locative</h1>
           <p className="text-gray-500 mt-1">Connectez-vous à votre espace</p>
         </div>
+
+        {oidcConfig.enabled && (
+          <>
+            <button type="button" onClick={handleSso}
+              className="w-full flex items-center justify-center gap-2 py-3 px-4 border-2 border-blue-600 text-blue-600 rounded-xl font-medium hover:bg-blue-50 transition-colors mb-4">
+              🔑 {oidcConfig.label}
+            </button>
+            <div className="relative mb-4">
+              <div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-200" /></div>
+              <div className="relative text-center text-sm text-gray-400"><span className="bg-white px-3">ou</span></div>
+            </div>
+          </>
+        )}
+
         <form onSubmit={handleSubmit} className="space-y-4">
           <div>
             <label className="label">Email</label>

+ 55 - 0
frontend/src/pages/OAuthCallback.jsx

@@ -0,0 +1,55 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '../api'
+import { useAuth } from '../context/AuthContext'
+import toast from 'react-hot-toast'
+
+/**
+ * Page de retour après authentification OIDC.
+ * Le backend redirige vers /auth/callback#token=xxx
+ */
+export default function OAuthCallback() {
+  const navigate = useNavigate()
+  const { setUser } = useAuth()
+
+  useEffect(() => {
+    const hash = window.location.hash.slice(1)
+    const params = new URLSearchParams(hash)
+    const token = params.get('token')
+
+    const search = new URLSearchParams(window.location.search)
+    const error = search.get('error')
+
+    if (error) {
+      toast.error('Erreur de connexion SSO : ' + error)
+      navigate('/login')
+      return
+    }
+
+    if (!token) {
+      toast.error('Token manquant après connexion SSO')
+      navigate('/login')
+      return
+    }
+
+    localStorage.setItem('token', token)
+    api.get('/auth/me')
+      .then(res => {
+        setUser(res.data)
+        navigate('/')
+      })
+      .catch(() => {
+        toast.error('Impossible de récupérer le profil')
+        navigate('/login')
+      })
+  }, [navigate, setUser])
+
+  return (
+    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
+      <div className="text-center">
+        <div className="text-4xl mb-4">⏳</div>
+        <p className="text-gray-600">Connexion en cours…</p>
+      </div>
+    </div>
+  )
+}