ajoute Moodle LTI 1.3 (V2), une 6e façon de rejoindre une classe

LTI authentifie l'élève et le fait rejoindre sa classe (même code que
le code/QR existant), pas lancer un lab précis - relecture du
CLAUDE.md qui liste LTI avec les autres façons d'ajouter des élèves.
packbackbooks/lti-1p3-tool (Apache-2.0, recommandé par le CLAUDE.md),
trois ponts vers Redis/cookies/MariaDB, élève retrouvé par identité
Moodle stable pour ne jamais créer de doublon. Enregistrement d'une
plateforme/d'un déploiement par commande Artisan (opération rare,
faite à la main par Adam), jamais une page d'administration.

Corrige au passage un vrai bug préexistant trouvé en vérifiant en réel
sur le serveur : withoutMiddleware(ValidateCsrfToken::class) sur une
route ne retire jamais le vrai middleware CSRF (ValidateCsrfToken est
un alias déprécié de PreventRequestForgery, pas la classe réellement
enregistrée) - /csp-rapport avait le même défaut depuis l'étape 14,
invisible faute de surveillance. Corrigé pour les deux par la bonne
méthode (validateCsrfTokens except, dans bootstrap/app.php).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 13:51:47 +02:00
parent 777dd1cbc8
commit 91d7cde4c1
27 changed files with 1293 additions and 9 deletions

View file

@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Eleve;
use App\Services\GenerateurCompteEleve;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
use Packback\Lti1p3\Factories\MessageFactory;
use Packback\Lti1p3\LtiException;
use Packback\Lti1p3\OidcException;
/**
* Deuxième étape du lancement LTI 1.3 (V2, CLAUDE.md section 6) : Moodle
* poste ici le jeton signé après la connexion OIDC
* (LtiConnexionController). L'URL (une par classe, affichée dans « Faire
* rejoindre tes élèves ») est à coller comme adresse de l'outil externe
* dans l'activité Moodle du prof.
*/
class LtiLancementController extends Controller
{
public function lancer(Request $request, string $codeClasse, MessageFactory $factory): View|RedirectResponse
{
$classe = Classe::where('code_classe', $codeClasse)->first();
if ($classe === null || $classe->estArchivee()) {
return view('lti.erreur', ['message' => 'Classe introuvable ou archivée.']);
}
try {
$lancement = $factory->create($request->all());
} catch (LtiException|OidcException $e) {
return view('lti.erreur', ['message' => $e->getMessage()]);
}
$donnees = $lancement->getBody();
$eleve = $this->trouverOuCreerEleve($classe, (string) $donnees['iss'], (string) $donnees['sub'], $donnees);
Auth::guard('eleve')->login($eleve);
$request->session()->regenerate();
$eleve->forceFill(['derniere_connexion_a' => now()])->save();
$request->session()->put('jeton_session_eleve', $eleve->demarreNouvelleSession());
return redirect()->route('eleve');
}
/**
* @param array<string, mixed> $claims
*/
private function trouverOuCreerEleve(Classe $classe, string $emetteur, string $sub, array $claims): Eleve
{
$eleve = Eleve::where('classe_id', $classe->id)
->where('lti_emetteur', $emetteur)
->where('lti_sub', $sub)
->first();
if ($eleve !== null) {
return $eleve;
}
[$prenom, $nom] = $this->nomDepuisClaims($claims);
$generateur = app(GenerateurCompteEleve::class);
return $classe->eleves()->create([
'prenom' => $prenom,
'nom' => $nom,
'identifiant' => $generateur->genererIdentifiant($prenom, $nom),
// Jamais utilisé : cet élève se connecte toujours via Moodle.
'code_secret' => Hash::make($generateur->genererCodeSecret()),
'lti_emetteur' => $emetteur,
'lti_sub' => $sub,
]);
}
/**
* @param array<string, mixed> $claims
* @return array{0: string, 1: ?string}
*/
private function nomDepuisClaims(array $claims): array
{
if (filled($claims['given_name'] ?? null)) {
return [$claims['given_name'], $claims['family_name'] ?? null];
}
if (filled($claims['name'] ?? null)) {
$parties = explode(' ', trim($claims['name']), 2);
return [$parties[0], $parties[1] ?? null];
}
return ['Élève', 'Moodle'];
}
}