From 07ab2a706374e4d47a92fe19561bec6b5a7a5e95 Mon Sep 17 00:00:00 2001 From: yann64 Date: Thu, 4 Jun 2026 18:59:18 +0200 Subject: [PATCH] Configuration SMTP et 2FA par code PIN e-mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paramètres du site : - Nouvelle section "Serveur SMTP" avec host, port, chiffrement, identifiant, mot de passe, adresse/nom d'expéditeur - Bouton "Envoyer un e-mail de test" (AJAX via Symfony EsmtpTransport) : tente la connexion + envoie un message réel à l'admin - Badge "Configuré — 2FA actif" quand SMTP est en place - Suppression de la configuration possible Authentification 2FA : - Si SMTP configuré : après validation identifiant/mot de passe, l'utilisateur est déconnecté, un PIN à 6 chiffres est généré, haché (bcrypt) et stocké en session, envoyé par e-mail (10 min) - Page /2fa : saisie du PIN, bouton "Renvoyer le code", retour login - Si l'envoi e-mail échoue : fallback sans 2FA (logue l'erreur) - Si SMTP non configuré : login standard inchangé Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Admin/SettingController.php | 85 +++++++++ .../Auth/AuthenticatedSessionController.php | 46 +++-- .../Controllers/Auth/TwoFactorController.php | 103 +++++++++++ app/Mail/TwoFactorPinMail.php | 32 ++++ app/Providers/AppServiceProvider.php | 23 ++- app/Services/SiteSettingsService.php | 13 ++ .../views/admin/parametres/index.blade.php | 162 ++++++++++++++++++ resources/views/auth/two-factor.blade.php | 52 ++++++ .../views/emails/two-factor-pin.blade.php | 46 +++++ routes/admin.php | 13 +- routes/web.php | 8 + 11 files changed, 564 insertions(+), 19 deletions(-) create mode 100644 app/Http/Controllers/Auth/TwoFactorController.php create mode 100644 app/Mail/TwoFactorPinMail.php create mode 100644 resources/views/auth/two-factor.blade.php create mode 100644 resources/views/emails/two-factor-pin.blade.php diff --git a/app/Http/Controllers/Admin/SettingController.php b/app/Http/Controllers/Admin/SettingController.php index 3e6c8bd..d0a7a60 100644 --- a/app/Http/Controllers/Admin/SettingController.php +++ b/app/Http/Controllers/Admin/SettingController.php @@ -5,10 +5,14 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Services\SiteSettingsService; use App\Services\UpdateService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; class SettingController extends Controller { @@ -61,6 +65,87 @@ class SettingController extends Controller return back()->with('success', 'Logo supprimé.'); } + // ── SMTP ────────────────────────────────────────────────────────────────── + + public function updateSmtp(Request $request): RedirectResponse + { + $data = $request->validate([ + 'smtp_host' => ['required', 'string', 'max:255'], + 'smtp_port' => ['required', 'integer', 'min:1', 'max:65535'], + 'smtp_encryption' => ['nullable', 'in:tls,ssl'], + 'smtp_username' => ['nullable', 'string', 'max:255'], + 'smtp_password' => ['nullable', 'string', 'max:255'], + 'smtp_from_address' => ['required', 'email', 'max:255'], + 'smtp_from_name' => ['required', 'string', 'max:255'], + ]); + + SiteSettingsService::set('smtp', [ + 'host' => $data['smtp_host'], + 'port' => (int) $data['smtp_port'], + 'encryption' => $data['smtp_encryption'] ?? null, + 'username' => $data['smtp_username'] ?? null, + 'password' => $data['smtp_password'] ?? null, + 'from_address' => $data['smtp_from_address'], + 'from_name' => $data['smtp_from_name'], + ]); + + return back()->with('success', 'Configuration SMTP enregistrée. Le 2FA par e-mail est maintenant actif.'); + } + + public function deleteSmtp(): RedirectResponse + { + SiteSettingsService::set('smtp', []); + + return back()->with('success', 'Configuration SMTP supprimée. Le 2FA est désactivé.'); + } + + public function testSmtp(Request $request): JsonResponse + { + $data = $request->validate([ + 'smtp_host' => ['required', 'string'], + 'smtp_port' => ['required', 'integer'], + 'smtp_encryption' => ['nullable', 'in:tls,ssl'], + 'smtp_username' => ['nullable', 'string'], + 'smtp_password' => ['nullable', 'string'], + 'smtp_from_address' => ['required', 'email'], + 'smtp_from_name' => ['required', 'string'], + ]); + + try { + $useSsl = ($data['smtp_encryption'] ?? '') === 'ssl'; + $transport = new EsmtpTransport($data['smtp_host'], (int) $data['smtp_port'], $useSsl); + + if (! empty($data['smtp_username'])) { + $transport->setUsername($data['smtp_username']); + $transport->setPassword($data['smtp_password'] ?? ''); + } + + $mailer = new \Symfony\Component\Mailer\Mailer($transport); + + $email = (new Email()) + ->from(new Address($data['smtp_from_address'], $data['smtp_from_name'])) + ->to(auth()->user()->email) + ->subject('Test SMTP — ' . config('app.name')) + ->text( + "Ce message confirme que votre configuration SMTP fonctionne correctement.\n\n" . + "Serveur : {$data['smtp_host']}:{$data['smtp_port']}\n" . + "Chiffrement : " . ($data['smtp_encryption'] ?: 'aucun') . "\n\n" . + "— " . config('app.name') + ); + + $mailer->send($email); + + return response()->json([ + 'ok' => true, + 'message' => 'E-mail de test envoyé à ' . auth()->user()->email . '. Vérifiez votre boîte de réception.', + ]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); + } + } + + // ── Paramètres généraux ─────────────────────────────────────────────────── + public function updateSettings(Request $request): RedirectResponse { $data = $request->validate([ diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 613bcd9..f27ffa4 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,42 +4,68 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Mail\TwoFactorPinMail; +use App\Services\SiteSettingsService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\View\View; class AuthenticatedSessionController extends Controller { - /** - * Display the login view. - */ public function create(): View { return view('auth.login'); } - /** - * Handle an incoming authentication request. - */ public function store(LoginRequest $request): RedirectResponse { $request->authenticate(); + // Si SMTP configuré → 2FA par code PIN + if (SiteSettingsService::smtpConfigured()) { + $user = Auth::user(); + $intended = $request->session()->pull('url.intended', route('dashboard')); + + $pin = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + // Déconnecter et stocker les données 2FA dans la session + Auth::logout(); + $request->session()->regenerate(); + $request->session()->put([ + '2fa.user_id' => $user->id, + '2fa.pin_hash' => Hash::make($pin), + '2fa.expires_at' => now()->addMinutes(10)->timestamp, + '2fa.intended' => $intended, + ]); + + try { + Mail::to($user->email)->send(new TwoFactorPinMail($pin, $user->name)); + } catch (\Exception $e) { + // Si l'envoi échoue, on logue l'erreur et on laisse passer sans 2FA + Log::error('2FA: envoi du PIN impossible.', ['error' => $e->getMessage()]); + $request->session()->forget('2fa'); + Auth::login($user); + $request->session()->regenerate(); + return redirect($intended); + } + + return redirect()->route('2fa.challenge'); + } + $request->session()->regenerate(); return redirect()->intended(route('dashboard', absolute: false)); } - /** - * Destroy an authenticated session. - */ public function destroy(Request $request): RedirectResponse { Auth::guard('web')->logout(); $request->session()->invalidate(); - $request->session()->regenerateToken(); return redirect('/'); diff --git a/app/Http/Controllers/Auth/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactorController.php new file mode 100644 index 0000000..491130d --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorController.php @@ -0,0 +1,103 @@ +session()->has('2fa.user_id')) { + return redirect()->route('login'); + } + + $user = User::find($request->session()->get('2fa.user_id')); + if (! $user) { + $request->session()->forget('2fa'); + return redirect()->route('login'); + } + + return view('auth.two-factor', [ + 'maskedEmail' => $this->maskEmail($user->email), + ]); + } + + public function verify(Request $request): RedirectResponse + { + $request->validate(['pin' => ['required', 'string', 'digits:6']]); + + $userId = $request->session()->get('2fa.user_id'); + $pinHash = $request->session()->get('2fa.pin_hash'); + $expiresAt = $request->session()->get('2fa.expires_at'); + + if (! $userId || ! $pinHash) { + return redirect()->route('login') + ->withErrors(['email' => 'Session expirée. Veuillez vous reconnecter.']); + } + + if (now()->timestamp > (int) $expiresAt) { + $request->session()->forget('2fa'); + return redirect()->route('login') + ->withErrors(['email' => 'Le code PIN a expiré. Veuillez vous reconnecter.']); + } + + if (! Hash::check($request->input('pin'), $pinHash)) { + return back()->withErrors(['pin' => 'Code incorrect. Vérifiez votre e-mail et réessayez.']); + } + + // PIN valide — authentifier l'utilisateur + $user = User::findOrFail($userId); + $intended = $request->session()->pull('2fa.intended', route('dashboard')); + + $request->session()->forget('2fa'); + Auth::login($user); + $request->session()->regenerate(); + + return redirect($intended); + } + + public function resend(Request $request): RedirectResponse + { + $userId = $request->session()->get('2fa.user_id'); + if (! $userId) { + return redirect()->route('login'); + } + + $user = User::find($userId); + if (! $user) { + return redirect()->route('login'); + } + + $pin = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + $request->session()->put([ + '2fa.pin_hash' => Hash::make($pin), + '2fa.expires_at' => now()->addMinutes(10)->timestamp, + ]); + + try { + Mail::to($user->email)->send(new TwoFactorPinMail($pin, $user->name)); + } catch (\Exception $e) { + return back()->withErrors(['pin' => "Impossible d'envoyer le code : " . $e->getMessage()]); + } + + return back()->with('resent', true); + } + + private function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email, 2); + $visible = min(2, strlen($local)); + $masked = substr($local, 0, $visible) . str_repeat('*', max(strlen($local) - $visible, 3)); + return $masked . '@' . $domain; + } +} diff --git a/app/Mail/TwoFactorPinMail.php b/app/Mail/TwoFactorPinMail.php new file mode 100644 index 0000000..3529e66 --- /dev/null +++ b/app/Mail/TwoFactorPinMail.php @@ -0,0 +1,32 @@ + SiteSettingsService::siteName()]); + + // Applique la configuration SMTP si elle est définie dans les paramètres + if (SiteSettingsService::smtpConfigured()) { + $smtp = SiteSettingsService::smtpConfig(); + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp.host' => $smtp['host'], + 'mail.mailers.smtp.port' => (int) ($smtp['port'] ?? 587), + 'mail.mailers.smtp.encryption' => $smtp['encryption'] ?: null, + 'mail.mailers.smtp.username' => $smtp['username'] ?? null, + 'mail.mailers.smtp.password' => $smtp['password'] ?? null, + 'mail.from.address' => $smtp['from_address'] ?? null, + 'mail.from.name' => $smtp['from_name'] ?? SiteSettingsService::siteName(), + ]); + } } } diff --git a/app/Services/SiteSettingsService.php b/app/Services/SiteSettingsService.php index f52e68d..0b0e879 100644 --- a/app/Services/SiteSettingsService.php +++ b/app/Services/SiteSettingsService.php @@ -60,6 +60,19 @@ class SiteSettingsService } } + // ── SMTP ───────────────────────────────────────────────────────────────── + + public static function smtpConfig(): array + { + return self::get('smtp', []); + } + + public static function smtpConfigured(): bool + { + $smtp = self::smtpConfig(); + return ! empty($smtp['host']) && ! empty($smtp['port']); + } + // ── Titre du site ───────────────────────────────────────────────────────── public static function siteName(): string diff --git a/resources/views/admin/parametres/index.blade.php b/resources/views/admin/parametres/index.blade.php index 9125309..0c3b46e 100644 --- a/resources/views/admin/parametres/index.blade.php +++ b/resources/views/admin/parametres/index.blade.php @@ -48,6 +48,168 @@ + {{-- SMTP --}} + @php $smtp = \App\Services\SiteSettingsService::smtpConfig(); @endphp +
+ +
+
+

Serveur SMTP

+

+ Quand configuré, la connexion nécessitera un code PIN envoyé par e-mail (2FA). +

+
+ @if(\App\Services\SiteSettingsService::smtpConfigured()) + + + + + Configuré — 2FA actif + + @endif +
+ +
+ @csrf + +
+
+ + + @error('smtp_host')

{{ $message }}

@enderror +
+
+ + + @error('smtp_port')

{{ $message }}

@enderror +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + @error('smtp_from_address')

{{ $message }}

@enderror +
+
+ + + @error('smtp_from_name')

{{ $message }}

@enderror +
+
+ + {{-- Résultat test --}} +
+ + +
+ +
+ + + + + @if(\App\Services\SiteSettingsService::smtpConfigured()) + + @csrf @method('DELETE') + + + @endif +
+ +
+ {{-- Version --}}

Version du logiciel

diff --git a/resources/views/auth/two-factor.blade.php b/resources/views/auth/two-factor.blade.php new file mode 100644 index 0000000..41fa0fc --- /dev/null +++ b/resources/views/auth/two-factor.blade.php @@ -0,0 +1,52 @@ + +
+
+ + + +
+

Vérification en deux étapes

+

+ Un code à 6 chiffres a été envoyé à
+ {{ $maskedEmail }} +

+
+ + @if(session('resent')) +
+ Un nouveau code a été envoyé. +
+ @endif + +
+ @csrf + +
+ + + +
+ + + Valider le code + +
+ +
+
+ @csrf + +
+ + ← Retour à la connexion + +
+
diff --git a/resources/views/emails/two-factor-pin.blade.php b/resources/views/emails/two-factor-pin.blade.php new file mode 100644 index 0000000..cb6f873 --- /dev/null +++ b/resources/views/emails/two-factor-pin.blade.php @@ -0,0 +1,46 @@ + + + + + + Code de connexion + + + +
+
+

{{ config('app.name') }}

+
+
+

Bonjour {{ $userName }},

+

Voici votre code de connexion à usage unique :

+ +
+
{{ $pin }}
+

Ce code expire dans 10 minutes.

+
+ +

Si vous n'avez pas essayé de vous connecter, ignorez cet e-mail et votre compte restera sécurisé.

+
+ +
+ + diff --git a/routes/admin.php b/routes/admin.php index 2e31ca1..fef9ca2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -12,11 +12,14 @@ use Illuminate\Support\Facades\Route; Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); - // Paramètres du site (logo, inscriptions) - Route::get('parametres', [SettingController::class, 'index'])->name('parametres'); - Route::post('parametres/logo', [SettingController::class, 'updateLogo'])->name('parametres.logo.update'); - Route::delete('parametres/logo', [SettingController::class, 'deleteLogo'])->name('parametres.logo.delete'); - Route::post('parametres/settings', [SettingController::class, 'updateSettings'])->name('parametres.update'); + // Paramètres du site (logo, inscriptions, SMTP) + Route::get('parametres', [SettingController::class, 'index'])->name('parametres'); + Route::post('parametres/logo', [SettingController::class, 'updateLogo'])->name('parametres.logo.update'); + Route::delete('parametres/logo', [SettingController::class, 'deleteLogo'])->name('parametres.logo.delete'); + Route::post('parametres/settings', [SettingController::class, 'updateSettings'])->name('parametres.update'); + Route::post('parametres/smtp', [SettingController::class, 'updateSmtp'])->name('parametres.smtp.update'); + Route::delete('parametres/smtp', [SettingController::class, 'deleteSmtp'])->name('parametres.smtp.delete'); + Route::post('parametres/smtp/test', [SettingController::class, 'testSmtp'])->name('parametres.smtp.test'); // Routes spécifiques avant la resource pour éviter les conflits de paramètre Route::get('utilisateurs/export', [UserController::class, 'export'])->name('utilisateurs.export'); diff --git a/routes/web.php b/routes/web.php index 54f9c14..834baaa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\NotificationController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\RechercheController; use App\Http\Controllers\ReleveController; +use App\Http\Controllers\Auth\TwoFactorController; use App\Http\Controllers\SetupController; use App\Http\Controllers\SourceController; use Illuminate\Support\Facades\Route; @@ -23,6 +24,13 @@ Route::prefix('setup')->name('setup.')->group(function () { Route::post('/install', [SetupController::class, 'install'])->name('install'); }); +// ── Authentification 2FA par code PIN ─────────────────────────────────────── +Route::middleware('guest')->group(function () { + Route::get('2fa', [TwoFactorController::class, 'challenge'])->name('2fa.challenge'); + Route::post('2fa', [TwoFactorController::class, 'verify'])->name('2fa.verify'); + Route::post('2fa/resend', [TwoFactorController::class, 'resend'])->name('2fa.resend'); +}); + Route::get('/', function () { return view('welcome'); });