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,76 @@
# ADR 0010 — LTI 1.3 Moodle : une 6ᵉ façon de rejoindre une classe, pas un lancement de lab
- **Date** : 2026-10-14
- **Statut** : accepté
## Contexte
CLAUDE.md, section 6 : « Moodle LTI 1.3 (V2) » est listé **avec** les autres
façons d'ajouter des élèves (code/QR, import CSV, saisie rapide, invitation
e-mail) — pas dans la description de l'espace de lab. Avancé à la demande
explicite d'Adam (bloc C terminé, V2 lancée par anticipation).
## Décisions
**LTI sert à rejoindre une classe, pas à lancer un lab précis.** Un
lancement Moodle authentifie l'élève (OIDC + jeton signé) et le fait
atterrir sur son tableau de bord habituel (`eleve.show`) — depuis là, il
choisit un lab ou un devoir normalement, par le portail. Construire un
lien direct vers un lab précis aurait demandé de router l'admission dans
une séance (`SeanceController::admettre`), bien plus complexe, pour un
besoin que le CLAUDE.md ne demande pas.
**L'URL de lancement réutilise `code_classe` existant**
(`/lti/lancement/{code_classe}`), pas un nouveau jeton ou une nouvelle
table de liaison. Exactement la même donnée que celle déjà affichée pour
« Faire rejoindre tes élèves » (code/QR) — LTI est juste une 6ᵉ porte vers
la même classe, pas un concept à part.
**`packbackbooks/lti-1p3-tool` (Apache-2.0)**, recommandé par le CLAUDE.md.
Bibliothèque bas niveau (pas de dépendance à un framework) : il faut lui
fournir trois implémentations (`ICache`, `ICookie`, `IDatabase`, dans
`App\Services\Lti\`) qui s'appuient sur Redis, les cookies et les tables
`lti_plateformes`/`lti_deploiements` déjà en place.
**Élève provisionné automatiquement au premier lancement, retrouvé par
`(classe_id, lti_emetteur, lti_sub)` ensuite** — jamais de doublon de
compte à chaque connexion. Prénom/nom lus depuis les claims standards
(`given_name`/`family_name`, puis `name`, jamais inventés : « Élève » /
« Moodle » si Moodle n'envoie rien). Code secret généré au hasard, haché,
jamais affiché : cet élève se connecte toujours via Moodle, jamais par
identifiant/code comme les autres.
**Cookie `SameSite=None; Secure` pour le jeton d'état OIDC.** Sans ça, le
cookie ne survit jamais au POST final que Moodle envoie depuis son propre
domaine — chaque lancement échouerait silencieusement (« Please make sure
you have cookies… », message déjà prévu par la bibliothèque pour ce cas
précis, qui n'arrive qu'avec un réglage de cookie insuffisant).
**Sécurité x-pack… non, pas de rapport ici — mais même logique que pour
Elasticsearch/Ceph** : une seule clé RSA par plateforme Moodle enregistrée
(générée par le portail lui-même à l'enregistrement, `phpseclib4\Crypt\RSA`,
jamais collée par Adam), exposée en public via `/lti/cles.json` — c'est le
fonctionnement normal du protocole (Moodle doit pouvoir vérifier les jetons
que *nous* lui envoyons, si jamais le portail appelle un service LTI plus
tard), pas une fuite.
**Enregistrement manuel, par commande Artisan, jamais une page
d'administration.** Une plateforme Moodle se configure une fois par
établissement, par Adam, avec des informations lues dans l'administration
Moodle — une page web pour ça serait une fonctionnalité sans usage réel
(CLAUDE.md, section 8). `lti:enregistrer-plateforme` et
`lti:enregistrer-deploiement`, voir `docs/runbooks/lti-moodle.md`.
## Conséquences
- Testé avec un jeton LTI réellement signé (RS256, paire de clés générée
dans le test), jeu de clés de « Moodle » simulé : lancement validé de
bout en bout, élève créé avec le bon prénom/nom, deuxième lancement du
même élève ne crée pas de deuxième compte. Pas de vrai Moodle disponible
pour vérifier en conditions réelles — à refaire dès qu'Adam a un Moodle
de test.
- En dette : pas de web service LTI (AGS pour les notes, NRPS pour la
liste des inscrits, Deep Linking) — seul le lancement de ressource est
géré, comme décidé ci-dessus. Les classes de la bibliothèque pour ces
services existent déjà (`LtiAssignmentsGradesService` etc.) si le besoin
devient réel.

View file

@ -41,3 +41,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
| Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 |
| WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 |
| Recherche (Elasticsearch) : pas de pagination ni de facettes | Catalogue minuscule aujourd'hui (quelques labs, un seul vrai `cours.md`) : inutile tant que ça reste le cas. Voir [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md). | Si le catalogue grossit réellement | 2026-10-14 |
| LTI Moodle : pas de services AGS (notes)/NRPS (liste des inscrits)/Deep Linking, pas vérifié avec un vrai Moodle | Seul le lancement de ressource est géré (rejoindre une classe) — décision assumée, voir [ADR 0010](adr/0010-lti-moodle-comme-sixieme-facon-de-rejoindre.md). Testé avec un jeton réellement signé mais un Moodle simulé ; à revérifier dès qu'un vrai Moodle de test existe. | Si le besoin de notes/liste d'inscrits devient réel ; dès qu'un Moodle de test existe | 2026-10-14 |

View file

@ -1274,3 +1274,27 @@ Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, me
**Suite**
Bloc C restant : WebDAV et Ceph faits, Elasticsearch fait. ElasticSearch avait été écarté du MCP développeurs par Adam (pas pour l'instant). Prochains chantiers confirmés par Adam : LTI Moodle 1.3, puis SSO ENT (Keycloak) — normalement des étapes V2/V3 du produit, avancées à la demande explicite d'Adam.
## 2026-10-14 — LTI Moodle 1.3, avancé en V2 (sur demande d'Adam)
Bloc C terminé. Adam a demandé d'avancer directement sur la V2 : LTI Moodle 1.3, puis SSO ENT. Détail des choix : [ADR 0010](adr/0010-lti-moodle-comme-sixieme-facon-de-rejoindre.md), marche à suivre : `docs/runbooks/lti-moodle.md`.
**Fait**
- Relu le CLAUDE.md avant de commencer : « Moodle LTI 1.3 » est listé avec les autres façons d'ajouter des élèves (code/QR, CSV…), pas avec l'espace de lab — a recadré toute la conception : LTI fait simplement rejoindre une classe (comme le code/QR existant), pas lancer un lab précis.
- `packbackbooks/lti-1p3-tool` (Apache-2.0, recommandé par le CLAUDE.md) : trois ponts vers Redis/cookies/MariaDB (`App\Services\Lti\{CacheLti,CookieLti,DatabaseLti}`), deux nouvelles tables (`lti_plateformes`, `lti_deploiements`), deux colonnes sur `eleves` (`lti_emetteur`, `lti_sub`, pour retrouver le même élève à chaque lancement sans créer de doublon).
- URL de lancement par classe : `https://syllabuslab.fr/lti/lancement/<code_classe>` — réutilise le code déjà affiché pour « Faire rejoindre tes élèves », pas un nouveau concept. Visible sur la page de la classe, section « Lier à Moodle ».
- Élève provisionné automatiquement au premier lancement (prénom/nom lus dans les claims Moodle, jamais inventés ; « Élève »/« Moodle » si Moodle n'en envoie pas), retrouvé ensuite par identité Moodle stable — jamais de compte en double.
- Deux commandes Artisan (`lti:enregistrer-plateforme`, `lti:enregistrer-deploiement`) : l'enregistrement d'un Moodle est une opération rare, faite à la main par Adam avec des informations lues dans l'administration Moodle — jamais une page web pour ça (CLAUDE.md : pas de fonctionnalité sans besoin réel).
- Cookie d'état OIDC en `SameSite=None; Secure` : sans ça, le cookie ne survit jamais au POST final que Moodle envoie depuis son propre domaine — repéré en lisant le code de la bibliothèque (son message d'erreur dédié à ce cas précis), pas après un échec en vrai.
- 7 tests Pest, dont un lancement **réellement signé** de bout en bout (vraie paire de clés RSA générée dans le test, jeu de clés de « Moodle » simulé par un faux connecteur de service) : élève créé avec le bon prénom/nom, un deuxième lancement du même élève ne crée pas de deuxième compte, classe inconnue/archivée affiche une erreur propre au lieu d'une 500.
**Comprendre**
Pas de vrai Moodle pour vérifier ce chantier en conditions réelles (contrairement à Ceph/WebDAV/Elasticsearch, tous vérifiés sur le vrai serveur) — la bibliothèque LTI elle-même construit ses propres tests avec un jeton signé et un jeu de clés simulé plutôt qu'un vrai IMS Validator, et j'ai repris exactement ce principe : le test ne fait confiance à rien de magique, il signe un vrai jeton avec une vraie clé et vérifie que le portail le valide et en tire les bonnes conséquences. C'est la meilleure vérification possible sans Moodle réel, mais ça reste noté en dette — un vrai lancement Moodle peut révéler des détails qu'aucune bibliothèque de test ne reproduit (comportement des cookies tiers d'un vrai navigateur, en particulier).
**Bug préexistant trouvé en vérifiant en réel sur le serveur** : `/lti/lancement` répondait 419 (jeton CSRF) malgré un `->withoutMiddleware(ValidateCsrfToken::class)` posé sur la route — jamais vu en local, les tests Pest ne passent jamais par le vrai middleware CSRF. Cause : `ValidateCsrfToken` est une classe dépréciée, simple alias de `PreventRequestForgery` (la vraie classe réellement enregistrée dans le groupe `web`) — `withoutMiddleware` compare des noms de classe exacts, et ne retirait donc jamais la bonne. `/csp-rapport` avait exactement le même défaut depuis l'étape 14, invisible faute de surveillance de ses échecs (un navigateur qui échoue à poster un rapport CSP ne prévient personne). Corrigé pour les deux à la fois, par la vraie méthode (`$middleware->validateCsrfTokens(except: [...])` dans `bootstrap/app.php`), vérifié en réel après coup : les trois routes (`csp-rapport`, `lti/connexion`, `lti/lancement`) répondent maintenant comme attendu (204/400/200) au lieu de 419.
**Suite**
Prochain, confirmé par Adam : SSO ENT (Keycloak), V3 du CLAUDE.md.

View file

@ -0,0 +1,74 @@
# Runbook — Moodle LTI 1.3
6ᵉ façon de rejoindre une classe (V2, CLAUDE.md section 6) : un élève
clique sur l'activité Moodle, atterrit connecté sur son tableau de bord
Syllabus habituel. Détail des choix :
[ADR 0010](../adr/0010-lti-moodle-comme-sixieme-facon-de-rejoindre.md).
## Étape 1 — Côté Moodle : ajouter Syllabus comme outil externe
Administration du site → Plugins → Outils externes → Gérer les outils →
Configurer un outil manuellement. Renseigner :
| Champ Moodle | Valeur |
|---|---|
| URL de l'outil | `https://syllabuslab.fr/lti/lancement/<code de la classe>` (visible sur la page de la classe, section « Lier à Moodle ») |
| LTI version | 1.3 |
| Initiate login URL | `https://syllabuslab.fr/lti/connexion` |
| Redirection URIs | la même URL de lancement que ci-dessus |
| Public key type | Clé publique au format JWKS |
| Jeu de clés publiques (URL) | `https://syllabuslab.fr/lti/cles.json` |
Après enregistrement, Moodle **affiche** un « Platform ID », un
« Client ID », une « Public keyset URL », une « Access token URL » et une
« Authentication request URL » — ce sont ces valeurs-là (celles de
Moodle, pas les nôtres) qu'il faut passer à l'étape 2.
## Étape 2 — Côté serveur : enregistrer la plateforme
```
ssh paillasse
cd /var/www/portail
sudo -u www-data HOME=/tmp php8.3 artisan lti:enregistrer-plateforme \
"<Platform ID affiché par Moodle>" \
"<Client ID affiché par Moodle>" \
"<Public keyset URL affichée par Moodle>" \
"<Access token URL affichée par Moodle>" \
"<Authentication request URL affichée par Moodle>"
```
La commande génère et stocke une paire de clés RSA pour le portail (jamais
collée par Adam) et rappelle les deux adresses déjà saisies à l'étape 1.
## Étape 3 — Ajouter l'outil à un cours, puis enregistrer le déploiement
Dans le cours Moodle : ajouter une activité « Outil externe » pointant
vers Syllabus. Une fois ajoutée, Moodle affiche un **Deployment ID** pour
cette combinaison outil/cours — à enregistrer :
```
sudo -u www-data HOME=/tmp php8.3 artisan lti:enregistrer-deploiement <id_plateforme> <deployment_id>
```
`<id_plateforme>` est l'identifiant numérique affiché à l'étape 2
(« Plateforme #… enregistrée »).
## Vérifier que ça fonctionne
Un élève qui clique sur l'activité dans Moodle doit atterrir connecté sur
`/eleve`, dans la bonne classe. En cas d'échec, Moodle affiche en général
le message d'erreur renvoyé par le portail (page « Le lancement depuis
Moodle n'a pas fonctionné ») :
| Message | Cause probable |
|---|---|
| Classe introuvable ou archivée | Le code dans l'URL de lancement ne correspond à aucune classe active |
| LTI 1.3 Registration not found… | Étape 2 pas faite, ou Platform ID/Client ID mal recopiés |
| Unable to find deployment | Étape 3 pas faite pour ce cours précis |
| Please make sure you have cookies… | Cookies tiers bloqués côté navigateur de l'élève, ou horloge du serveur décalée |
## Un élève change d'identité Moodle ou rejoint depuis deux cours
Peu probable, mais si ça arrive : un nouveau compte serait créé (l'élève
est retrouvé par `lti_sub`, propre à Moodle). Pas de fusion automatique —
à faire à la main si ça se produit réellement.

View file

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\LtiDeploiement;
use App\Models\LtiPlateforme;
use Illuminate\Console\Command;
/**
* Enregistre un déploiement (V2, LTI 1.3) : Moodle en crée un chaque fois
* que l'outil est ajouté à un cours, affiché dans la configuration de
* l'activité. Un lancement dont le déploiement n'est pas enregistré est
* refusé.
*/
class EnregistrerDeploiementLti extends Command
{
protected $signature = 'lti:enregistrer-deploiement {plateforme_id} {deployment_id}';
protected $description = 'Enregistre un déploiement Moodle (ajout de l’outil à un cours)';
public function handle(): void
{
$plateforme = LtiPlateforme::find($this->argument('plateforme_id'));
if ($plateforme === null) {
$this->error('Plateforme introuvable.');
return;
}
LtiDeploiement::create([
'lti_plateforme_id' => $plateforme->id,
'deployment_id' => $this->argument('deployment_id'),
]);
$this->info('Déploiement enregistré.');
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Models\LtiPlateforme;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
// phpseclib/phpseclib ^4.0 est installé (composer.lock) : son espace de
// noms est phpseclib4, pas phpseclib3 (contrairement à la bibliothèque LTI
// elle-même, qui gère les deux — voir Packback\Lti1p3\JwksEndpoint).
use phpseclib4\Crypt\RSA;
/**
* Enregistre un établissement Moodle (V2, CLAUDE.md section 6) : à lancer
* une fois par Moodle, avec les informations que Moodle affiche après
* qu'Adam y a ajouté « Syllabus » comme outil externe LTI 1.3 (menu
* Administration du site > Plugins > Outils externes > Gérer les outils).
* Jamais les identifiants d'un élève ou d'un prof ; toujours ceux
* qu'Adam lit lui-même dans l'administration Moodle.
*/
class EnregistrerPlateformeLti extends Command
{
protected $signature = 'lti:enregistrer-plateforme
{emetteur : « Platform ID » affiché par Moodle}
{client_id : « Client ID » affiché par Moodle}
{url_jeu_cles : « Public keyset URL » affichée par Moodle}
{url_jeton_auth : « Access token URL » affichée par Moodle}
{url_connexion_auth : « Authentication request URL » affichée par Moodle}
{serveur_auth? : « Authentication server » — facultatif, souvent identique à l’émetteur}';
protected $description = 'Enregistre un établissement Moodle (plateforme LTI 1.3)';
public function handle(): void
{
$clePrivee = RSA::createKey(2048)->toString('PKCS8');
$kid = Str::random(16);
$plateforme = LtiPlateforme::create([
'emetteur' => $this->argument('emetteur'),
'client_id' => $this->argument('client_id'),
'url_jeu_cles' => $this->argument('url_jeu_cles'),
'url_jeton_auth' => $this->argument('url_jeton_auth'),
'url_connexion_auth' => $this->argument('url_connexion_auth'),
'serveur_auth' => $this->argument('serveur_auth'),
'cle_privee_outil' => $clePrivee,
'identifiant_cle' => $kid,
]);
$this->info("Plateforme #{$plateforme->id} enregistrée.");
$this->newLine();
$this->line('À saisir côté Moodle, dans la configuration de l’outil externe :');
$this->line(' Initiate login URL : '.route('lti.connexion'));
$this->line(' Public keyset URL (JWKS) : '.route('lti.cles'));
$this->newLine();
$this->line('Ensuite, pour chaque cours où l’outil est ajouté, Moodle affiche un');
$this->line('« Deployment ID » à enregistrer avec :');
$this->line(" php artisan lti:enregistrer-deploiement {$plateforme->id} <deployment_id>");
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\LtiPlateforme;
use Illuminate\Http\JsonResponse;
use Packback\Lti1p3\JwksEndpoint;
/**
* Clés publiques du portail (V2, LTI 1.3) : chaque plateforme Moodle
* enregistrée a sa propre paire de clés (une ligne de lti_plateformes),
* toutes publiées ici — Moodle n'en retient que celle dont le « kid »
* correspond à ses propres jetons signés.
*/
class LtiClesController extends Controller
{
public function cles(): JsonResponse
{
$cles = LtiPlateforme::query()
->whereNotNull('identifiant_cle')
->pluck('cle_privee_outil', 'identifiant_cle')
->all();
return response()->json(JwksEndpoint::new($cles)->getPublicJwks());
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ICookie;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\LtiOidcLogin;
use Packback\Lti1p3\OidcException;
/**
* Première étape du lancement LTI 1.3 (V2) : Moodle appelle cette URL
* (« initiate_login_url », enregistrée une fois par plateforme) pour
* démarrer la connexion OIDC, avant de rediriger l'utilisateur vers la
* vraie page de lancement — voir LtiLancementController.
*/
class LtiConnexionController extends Controller
{
public function connecter(Request $request, IDatabase $db, ICache $cache, ICookie $cookie): RedirectResponse
{
$donnees = $request->all();
if (! isset($donnees['target_link_uri'])) {
abort(400, 'target_link_uri manquant.');
}
try {
$url = LtiOidcLogin::new($db, $cache, $cookie)->getRedirectUrl($donnees['target_link_uri'], $donnees);
} catch (OidcException $e) {
abort(400, $e->getMessage());
}
return redirect()->away($url);
}
}

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'];
}
}

View file

@ -18,7 +18,7 @@ class Eleve extends Model implements AuthenticatableContract
/** @use HasFactory<EleveFactory> */
use Authenticatable, GerePreferences, GereSessionUnique, HasFactory;
protected $fillable = ['classe_id', 'prenom', 'nom', 'identifiant', 'code_secret', 'jeton_qr'];
protected $fillable = ['classe_id', 'prenom', 'nom', 'identifiant', 'code_secret', 'jeton_qr', 'lti_emetteur', 'lti_sub'];
protected $hidden = ['code_secret', 'jeton_session_actif', 'jeton_qr'];

View file

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LtiDeploiement extends Model
{
protected $fillable = ['lti_plateforme_id', 'deployment_id'];
/** @return BelongsTo<LtiPlateforme, $this> */
public function plateforme(): BelongsTo
{
return $this->belongsTo(LtiPlateforme::class, 'lti_plateforme_id');
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LtiPlateforme extends Model
{
protected $fillable = [
'emetteur', 'client_id', 'url_jeu_cles', 'url_jeton_auth',
'url_connexion_auth', 'serveur_auth', 'cle_privee_outil', 'identifiant_cle',
];
protected $hidden = ['cle_privee_outil'];
/** @return HasMany<LtiDeploiement, $this> */
public function deploiements(): HasMany
{
return $this->hasMany(LtiDeploiement::class);
}
}

View file

@ -5,12 +5,22 @@ namespace App\Providers;
use App\Models\Eleve;
use App\Models\User;
use App\Services\CompteurDevoirsEnAttente;
use App\Services\Lti\CacheLti;
use App\Services\Lti\CookieLti;
use App\Services\Lti\DatabaseLti;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ICookie;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\LtiServiceConnector;
class AppServiceProvider extends ServiceProvider
{
@ -19,7 +29,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Moodle LTI 1.3 (V2, CLAUDE.md section 6).
$this->app->bind(ICache::class, CacheLti::class);
$this->app->bind(ICookie::class, CookieLti::class);
$this->app->bind(IDatabase::class, DatabaseLti::class);
$this->app->bind(ILtiServiceConnector::class, fn ($app) => (new LtiServiceConnector($app->make(ICache::class), new Client))
->setDebuggingMode(config('app.debug')));
}
/**
@ -27,6 +42,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Tolérance d'horloge pour les jetons signés par Moodle (LTI 1.3).
JWT::$leeway = 5;
// Coquille de l'application (layouts/app) : variables communes à
// la barre du haut et à la barre latérale, pour qu'aucun contrôleur
// n'ait à les repasser (séparation vitrine/application, 2026-10-13).

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Lti;
use Illuminate\Support\Facades\Cache;
use Packback\Lti1p3\Interfaces\ICache;
/**
* Pont vers le cache Laravel (Redis en production) pour la bibliothèque
* LTI 1.3 : nonce OIDC, données d'un lancement en cours, jetons d'accès
* aux services LTI (jamais utilisés aujourd'hui, voir ADR).
*/
class CacheLti implements ICache
{
private const EXPIRATION_NONCE = 3600;
private const EXPIRATION_DONNEES_LANCEMENT = 86400;
private const EXPIRATION_JETON_ACCES = 3600;
/**
* @return array<string, mixed>|null
*/
public function getLaunchData(string $key): ?array
{
return Cache::get("lti1p3-lancement-{$key}");
}
/**
* @param array<string, mixed> $jwtBody
*/
public function cacheLaunchData(string $key, array $jwtBody): void
{
Cache::put("lti1p3-lancement-{$key}", $jwtBody, self::EXPIRATION_DONNEES_LANCEMENT);
}
public function cacheNonce(string $nonce, string $state): void
{
Cache::put("lti1p3-nonce-{$nonce}", $state, self::EXPIRATION_NONCE);
}
public function checkNonceIsValid(string $nonce, string $state): bool
{
$etatEnCache = Cache::get("lti1p3-nonce-{$nonce}");
if ($etatEnCache === null) {
return false;
}
Cache::forget("lti1p3-nonce-{$nonce}");
return $etatEnCache === $state;
}
public function cacheAccessToken(string $key, string $accessToken): void
{
Cache::put("lti1p3-jeton-{$key}", $accessToken, self::EXPIRATION_JETON_ACCES);
}
public function getAccessToken(string $key): ?string
{
return Cache::get("lti1p3-jeton-{$key}");
}
public function clearAccessToken(string $key): void
{
Cache::forget("lti1p3-jeton-{$key}");
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Services\Lti;
use Illuminate\Support\Facades\Cookie;
use Packback\Lti1p3\Interfaces\ICookie;
/**
* Pont vers les cookies Laravel, pour le jeton d'état OIDC du lancement
* LTI 1.3. SameSite=None obligatoire : la plateforme (Moodle) renvoie ce
* cookie via un POST depuis SON origine, pas la nôtre — un cookie "Lax" ou
* "Strict" ne partirait jamais, et chaque lancement échouerait en silence.
*/
class CookieLti implements ICookie
{
public function getCookie(string $name): ?string
{
return Cookie::get($name);
}
/**
* @param array<string, mixed> $options
*/
public function setCookie(string $name, string $value, int $exp = 3600, array $options = []): void
{
Cookie::queue($name, $value, (int) ceil($exp / 60), '/', null, true, true, false, 'none');
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Services\Lti;
use App\Models\LtiDeploiement;
use App\Models\LtiPlateforme;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiDeployment;
use Packback\Lti1p3\Interfaces\ILtiRegistration;
use Packback\Lti1p3\LtiDeployment;
use Packback\Lti1p3\LtiRegistration;
/**
* Pont vers les tables lti_plateformes/lti_deploiements : enregistrées à la
* main par Adam (`lti:enregistrer-plateforme`, `lti:enregistrer-deploiement`),
* jamais devinées ni créées par un lancement lui-même — un lancement dont
* la plateforme ou le déploiement est inconnu est refusé.
*/
class DatabaseLti implements IDatabase
{
public function findRegistrationByIssuer(string $iss, ?string $clientId = null): ?ILtiRegistration
{
$requete = LtiPlateforme::where('emetteur', $iss);
if ($clientId !== null) {
$requete->where('client_id', $clientId);
}
$plateforme = $requete->first();
if ($plateforme === null) {
return null;
}
return LtiRegistration::new([
'issuer' => $plateforme->emetteur,
'clientId' => $plateforme->client_id,
'keySetUrl' => $plateforme->url_jeu_cles,
'authTokenUrl' => $plateforme->url_jeton_auth,
'authLoginUrl' => $plateforme->url_connexion_auth,
'authServer' => $plateforme->serveur_auth,
'toolPrivateKey' => $plateforme->cle_privee_outil,
'kid' => $plateforme->identifiant_cle,
]);
}
public function findDeployment(string $iss, string $deploymentId, ?string $clientId = null): ?ILtiDeployment
{
$requete = LtiDeploiement::where('deployment_id', $deploymentId)
->whereHas('plateforme', function ($q) use ($iss, $clientId) {
$q->where('emetteur', $iss);
if ($clientId !== null) {
$q->where('client_id', $clientId);
}
});
$deploiement = $requete->first();
return $deploiement === null ? null : LtiDeployment::new($deploiement->deployment_id);
}
}

View file

@ -25,6 +25,14 @@ return Application::configure(basePath: dirname(__DIR__))
'ability' => CheckForAnyAbility::class,
]);
$middleware->web(append: [PolitiqueSecuriteContenu::class]);
// Postées directement par un navigateur ou par Moodle, jamais avec
// notre jeton CSRF : "->withoutMiddleware(ValidateCsrfToken::class)"
// sur la route elle-même ne suffit pas (constaté le 2026-10-14 en
// vérifiant /lti/lancement en réel : 419 malgré cet appel sur la
// route — /csp-rapport avait exactement le même défaut, jamais vu
// faute de surveillance de ses échecs). C'est cette exception
// globale, pas celle sur la route, qui fonctionne réellement.
$middleware->validateCsrfTokens(except: ['csp-rapport', 'lti/connexion', 'lti/lancement/*']);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View file

@ -14,6 +14,7 @@
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
"packbackbooks/lti-1p3-tool": "^6.4",
"symfony/yaml": "^7.4"
},
"require-dev": {

326
portail/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6f5b54180f0d3c00c4708c345a8ac918",
"content-hash": "d5437c093e6b2b4deeaffebf07e46fac",
"packages": [
{
"name": "aws/aws-crt-php",
@ -921,6 +921,80 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.2.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "0501f5729b9e2448516674aff07c7d1727869b8e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/0501f5729b9e2448516674aff07c7d1727869b8e",
"reference": "0501f5729b9e2448516674aff07c7d1727869b8e",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4||^8.0",
"phpfastcache/phpfastcache": "^9.2",
"phpseclib/phpseclib": "~3.0",
"phpspec/prophecy-phpunit": "^2.2",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
},
"type": "library",
"extra": {
"component": {
"id": "jwt",
"path": "Jwt",
"entry": "README.md",
"target": "googleapis/php-jwt.git"
}
},
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/googleapis/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.2.1"
},
"time": "2026-09-28T17:51:46+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@ -3278,6 +3352,136 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "packbackbooks/lti-1p3-tool",
"version": "v6.4.4",
"source": {
"type": "git",
"url": "https://github.com/packbackbooks/lti-1-3-php-library.git",
"reference": "a20c71b74c3152b3e9fd6c484600851561fec5eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/packbackbooks/lti-1-3-php-library/zipball/a20c71b74c3152b3e9fd6c484600851561fec5eb",
"reference": "a20c71b74c3152b3e9fd6c484600851561fec5eb",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^7.0",
"guzzlehttp/guzzle": "^7.0 || ^8.0",
"php": "^8.1",
"phpseclib/phpseclib": "^3.0 || ^4.0"
},
"require-dev": {
"laravel/pint": "^1.0",
"mockery/mockery": "^1.4",
"nesbot/carbon": "^2.43 || ^3.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^9.0|^10.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Packback\\Lti1p3\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Davo Hynds",
"email": "davo@packback.co"
},
{
"name": "Eric Tendian",
"email": "eric@packback.co"
},
{
"name": "Martin Lenord",
"email": "ims.m@rtin.dev"
}
],
"description": "A library used for building IMS-certified LTI 1.3 tool providers in PHP.",
"keywords": [
"LTI"
],
"support": {
"issues": "https://github.com/packbackbooks/lti-1-3-php-library/issues",
"source": "https://github.com/packbackbooks/lti-1-3-php-library/tree/v6.4.4"
},
"time": "2026-09-23T21:17:20+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.10.0",
@ -3353,6 +3557,126 @@
],
"time": "2026-08-24T00:54:40+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "4.0.2",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "1fe20b9acb08ce810cec24d8a9d02e7c13426e4c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/1fe20b9acb08ce810cec24d8a9d02e7c13426e4c",
"reference": "1fe20b9acb08ce810cec24d8a9d02e7c13426e4c",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^2|^3",
"php": ">=8.1",
"symfony/polyfill-php82": "^1.26"
},
"require-dev": {
"brianium/paratest": "^7.22",
"ext-xml": "*",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpunit/phpunit": "^13",
"squizlabs/php_codesniffer": "^3.7",
"vimeo/psalm": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib4\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
},
{
"name": "Jack Worman",
"email": "jack.worman@gmail.com",
"homepage": "https://jackworman.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "https://phpseclib.com/",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/4.0.2"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-10-07T19:55:38+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* V2 (CLAUDE.md, section 6) : Moodle LTI 1.3, une 6ᵉ façon de rejoindre une
* classe. Une « plateforme » = un établissement Moodle enregistré à la
* main par Adam (`lti:enregistrer-plateforme`), jamais par un élève ou un
* prof — les informations viennent de l'administration Moodle.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('lti_plateformes', function (Blueprint $table) {
$table->id();
$table->string('emetteur')->index();
$table->string('client_id')->index();
$table->string('url_jeu_cles');
$table->string('url_jeton_auth');
$table->string('url_connexion_auth');
$table->string('serveur_auth')->nullable();
// Clé du portail (pas de Moodle) : signe les appels que le
// portail ferait vers les services LTI (AGS, NRPS…), jamais
// utilisés aujourd'hui (voir ADR). Jamais dans Git, générée par
// le portail lui-même à l'enregistrement.
$table->text('cle_privee_outil');
$table->string('identifiant_cle')->nullable();
$table->timestamps();
$table->unique(['emetteur', 'client_id']);
});
}
public function down(): void
{
Schema::dropIfExists('lti_plateformes');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moodle crée un « deployment_id » distinct chaque fois que l'outil est
* ajouté à un cours : à enregistrer après coup (`lti:enregistrer-deploiement`),
* jamais deviné — un lancement dont le déploiement est inconnu est refusé.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('lti_deploiements', function (Blueprint $table) {
$table->id();
$table->foreignId('lti_plateforme_id')->constrained()->cascadeOnDelete();
$table->string('deployment_id');
$table->timestamps();
$table->unique(['lti_plateforme_id', 'deployment_id']);
});
}
public function down(): void
{
Schema::dropIfExists('lti_deploiements');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Un élève provisionné depuis Moodle (LTI) n'a pas de code secret qu'il
* utilise jamais : il se connecte toujours via Moodle. `lti_sub` (identité
* stable côté Moodle) sert à le retrouver d'un lancement à l'autre, sans
* créer un doublon de compte à chaque fois.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('eleves', function (Blueprint $table) {
$table->string('lti_emetteur')->nullable()->after('jeton_qr');
$table->string('lti_sub')->nullable()->after('lti_emetteur');
$table->unique(['classe_id', 'lti_emetteur', 'lti_sub']);
});
}
public function down(): void
{
Schema::table('eleves', function (Blueprint $table) {
$table->dropUnique(['classe_id', 'lti_emetteur', 'lti_sub']);
$table->dropColumn(['lti_emetteur', 'lti_sub']);
});
}
};

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Lancement Moodle impossible · Syllabus</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body style="font-family:system-ui;max-width:40rem;margin:4rem auto;padding:0 1.5rem;color:#13233A">
<h1 style="font-size:1.5rem">Le lancement depuis Moodle n'a pas fonctionné</h1>
<p style="font-weight:600">{{ $message }}</p>
<p style="color:#5f6368">Préviens ton établissement : c'est probablement l'adresse ou la configuration de l'outil externe dans Moodle qui est à revoir, pas ton compte.</p>
</body>
</html>

View file

@ -248,6 +248,12 @@
</div>
<div class="justify-self-center">{!! \App\Support\QrCode::svg(route('rejoindre', ['code' => $classe->code_classe])) !!}</div>
</section>
<section id="lti" class="mt-6 rounded-lg border border-[#dadce0] bg-white p-6" aria-labelledby="t-lti">
<h2 id="t-lti" class="text-2xl font-extrabold">Lier à Moodle (LTI 1.3)</h2>
<p class="mt-2 font-semibold">Si ton établissement a déjà configuré Syllabus comme outil externe dans Moodle (une fois, par l'administration), colle cette adresse comme URL de l'activité pour cette classe. Un élève qui clique dessus depuis Moodle rejoint automatiquement la classe, sans code à saisir.</p>
<p class="mt-4 break-all rounded-lg border border-[#e3e5e8] bg-[#f8f9fa] px-3 py-2 font-mono text-sm font-semibold">{{ route('lti.lancement', $classe->code_classe) }}</p>
</section>
@endunless
@if (session('fiches'))

View file

@ -17,6 +17,9 @@ use App\Http\Controllers\EleveTableauDeBordController;
use App\Http\Controllers\EmploiDuTempsController;
use App\Http\Controllers\EvenementExamenController;
use App\Http\Controllers\InscriptionController;
use App\Http\Controllers\LtiClesController;
use App\Http\Controllers\LtiConnexionController;
use App\Http\Controllers\LtiLancementController;
use App\Http\Controllers\MaClasseController;
use App\Http\Controllers\MesCoursController;
use App\Http\Controllers\MesDevoirsController;
@ -39,7 +42,6 @@ use App\Http\Controllers\SignalementController;
use App\Http\Controllers\TokenApiController;
use App\Http\Controllers\VisioController;
use App\Http\Controllers\WebdavAuthController;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Support\Facades\Route;
Route::view('/', 'accueil')->name('accueil');
@ -55,11 +57,23 @@ Route::view('/inscription', 'auth.inscription')->name('register');
Route::view('/classes/suivi', 'classes.suivi')->name('suivi');
Route::view('/examen', 'examen')->name('examen');
// À brancher sur l'envoi d'e-mail (Mail::to('adam@belkacemi.me')->send(...)).
// Envoyé par les navigateurs eux-mêmes (pas de jeton CSRF possible).
// Envoyé par les navigateurs eux-mêmes (pas de jeton CSRF possible) :
// exclusion dans bootstrap/app.php (validateCsrfTokens), pas ici —
// "withoutMiddleware" sur la route elle-même ne suffit pas (419 constaté
// le 2026-10-14 en vérifiant /lti/lancement en réel).
Route::post('/csp-rapport', RapportCspController::class)
->withoutMiddleware(ValidateCsrfToken::class)
->middleware('throttle:30,1')
->name('csp.rapport');
// Moodle LTI 1.3 (V2, CLAUDE.md section 6) : postés directement par Moodle
// depuis son propre domaine, jamais de jeton CSRF à notre nom — la
// sécurité du lancement vient de la signature du jeton LTI lui-même.
// Exclusion dans bootstrap/app.php, voir le commentaire ci-dessus.
Route::match(['get', 'post'], '/lti/connexion', [LtiConnexionController::class, 'connecter'])
->name('lti.connexion');
Route::post('/lti/lancement/{codeClasse}', [LtiLancementController::class, 'lancer'])
->name('lti.lancement');
Route::get('/lti/cles.json', [LtiClesController::class, 'cles'])->name('lti.cles');
Route::post('/contact', fn () => back()->with('contact_ok', true))->name('contact');
Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites');
Route::view('/legal', 'legal')->name('legal');

View file

@ -0,0 +1,167 @@
<?php
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\LtiDeploiement;
use App\Models\LtiPlateforme;
use Firebase\JWT\JWT;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Cookie;
use Mockery\MockInterface;
use Packback\Lti1p3\Claims\DeploymentId;
use Packback\Lti1p3\Claims\MessageType;
use Packback\Lti1p3\Claims\ResourceLink;
use Packback\Lti1p3\Claims\Roles;
use Packback\Lti1p3\Claims\TargetLinkUri;
use Packback\Lti1p3\Claims\Version;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\LtiConstants;
use Packback\Lti1p3\LtiOidcLogin;
use phpseclib4\Crypt\RSA;
function creerPlateformeLtiDeTest(): array
{
$cle = RSA::createKey(2048);
$plateforme = LtiPlateforme::create([
'emetteur' => 'https://moodle-test.exemple.fr',
'client_id' => 'client-test',
'url_jeu_cles' => 'https://moodle-test.exemple.fr/jwks',
'url_jeton_auth' => 'https://moodle-test.exemple.fr/token',
'url_connexion_auth' => 'https://moodle-test.exemple.fr/auth',
'cle_privee_outil' => $cle->toString('PKCS8'),
'identifiant_cle' => 'kid-portail',
]);
$deploiement = LtiDeploiement::create([
'lti_plateforme_id' => $plateforme->id,
'deployment_id' => 'depl-1',
]);
return [$plateforme, $deploiement, $cle];
}
function jwkMoodleDeTest($clePlateforme, string $kid): array
{
$jwk = json_decode($clePlateforme->getPublicKey()->toString('JWK'), true);
return array_merge($jwk['keys'][0], ['kid' => $kid, 'alg' => 'RS256', 'use' => 'sig']);
}
function jeuClésMoodleFausse(array $jwk): void
{
/** @var MockInterface $connecteur */
$connecteur = Mockery::mock(ILtiServiceConnector::class);
$connecteur->shouldReceive('makeRequest')->andReturn(new Response(200));
$connecteur->shouldReceive('getResponseBody')->andReturn(['keys' => [$jwk]]);
app()->instance(ILtiServiceConnector::class, $connecteur);
}
test('un lancement vers une classe inconnue affiche une erreur, jamais une 500', function () {
$this->post(route('lti.lancement', 'CODEINEXISTANT'))->assertOk()->assertSee('introuvable');
});
test('un lancement vers une classe archivée affiche une erreur', function () {
$classe = Classe::factory()->create(['archivee_a' => now()]);
$this->post(route('lti.lancement', $classe->code_classe))->assertOk()->assertSee('introuvable');
});
test('un id_token sans plateforme enregistrée affiche une erreur claire', function () {
$classe = Classe::factory()->create();
$this->post(route('lti.lancement', $classe->code_classe), ['id_token' => 'x.y.z', 'state' => 'abc'])
->assertOk()
->assertDontSee('Server Error');
});
test('un lancement valide crée l’élève (identité stable via lti_sub) et le connecte', function () {
[$plateforme, $deploiement, $cle] = creerPlateformeLtiDeTest();
$classe = Classe::factory()->create();
$etat = 'etat-de-test';
$nonce = 'nonce-de-test';
Cookie::queue(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat, 1);
app(ICache::class)->cacheNonce($nonce, $etat);
jeuClésMoodleFausse(jwkMoodleDeTest($cle, $plateforme->identifiant_cle));
$payload = [
'iss' => $plateforme->emetteur,
'aud' => $plateforme->client_id,
'sub' => 'etu-externe-42',
'exp' => now()->addMinutes(5)->timestamp,
'iat' => now()->timestamp,
'nonce' => $nonce,
'given_name' => 'Camille',
'family_name' => 'Dupont',
Version::claimKey() => LtiConstants::V1_3,
DeploymentId::claimKey() => $deploiement->deployment_id,
MessageType::claimKey() => 'LtiResourceLinkRequest',
Roles::claimKey() => [],
TargetLinkUri::claimKey() => route('lti.lancement', $classe->code_classe),
ResourceLink::claimKey() => ['id' => 'rl-1'],
];
$idToken = JWT::encode($payload, $cle->toString('PKCS8'), 'RS256', $plateforme->identifiant_cle);
$reponse = $this->withCookie(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat)
->post(route('lti.lancement', $classe->code_classe), ['id_token' => $idToken, 'state' => $etat]);
$reponse->assertRedirect(route('eleve'));
$eleve = Eleve::where('classe_id', $classe->id)->first();
expect($eleve)->not->toBeNull()
->and($eleve->lti_emetteur)->toBe($plateforme->emetteur)
->and($eleve->lti_sub)->toBe('etu-externe-42')
->and($eleve->prenom)->toBe('Camille')
->and($eleve->nom)->toBe('Dupont');
});
test('un deuxième lancement du même élève ne crée pas de deuxième compte', function () {
[$plateforme, , $cle] = creerPlateformeLtiDeTest();
$classe = Classe::factory()->create();
$eleveExistant = Eleve::factory()->create([
'classe_id' => $classe->id,
'lti_emetteur' => $plateforme->emetteur,
'lti_sub' => 'etu-externe-42',
]);
$etat = 'etat-2';
$nonce = 'nonce-2';
Cookie::queue(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat, 1);
app(ICache::class)->cacheNonce($nonce, $etat);
jeuClésMoodleFausse(jwkMoodleDeTest($cle, $plateforme->identifiant_cle));
$payload = [
'iss' => $plateforme->emetteur,
'aud' => $plateforme->client_id,
'sub' => 'etu-externe-42',
'exp' => now()->addMinutes(5)->timestamp,
'iat' => now()->timestamp,
'nonce' => $nonce,
Version::claimKey() => LtiConstants::V1_3,
DeploymentId::claimKey() => LtiDeploiement::where('lti_plateforme_id', $plateforme->id)->first()->deployment_id,
MessageType::claimKey() => 'LtiResourceLinkRequest',
Roles::claimKey() => [],
TargetLinkUri::claimKey() => route('lti.lancement', $classe->code_classe),
ResourceLink::claimKey() => ['id' => 'rl-1'],
];
$idToken = JWT::encode($payload, $cle->toString('PKCS8'), 'RS256', $plateforme->identifiant_cle);
$this->withCookie(LtiOidcLogin::COOKIE_PREFIX.$etat, $etat)
->post(route('lti.lancement', $classe->code_classe), ['id_token' => $idToken, 'state' => $etat])
->assertRedirect(route('eleve'));
expect(Eleve::where('classe_id', $classe->id)->count())->toBe(1);
expect(Eleve::first()->id)->toBe($eleveExistant->id);
});
test('les clés publiques du portail sont accessibles sans authentification', function () {
creerPlateformeLtiDeTest();
$this->get(route('lti.cles'))->assertOk()->assertJsonStructure(['keys']);
});
test('la connexion LTI exige une target_link_uri', function () {
$this->post(route('lti.connexion'), [])->assertStatus(400);
});

View file

@ -12,7 +12,7 @@ test('chaque page publique s’affiche sans erreur', function () {
&& $r->getName() !== null
&& ! str_contains($r->uri(), '{')
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || str_starts_with($r->uri(), 'interne/') || $r->uri() === 'up')
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || str_starts_with($r->uri(), 'interne/') || str_starts_with($r->uri(), 'lti/connexion') || $r->uri() === 'up')
// Redirections voulues (ex. /statut vers le site de statut ; pages de
// suivi d'une demande pour rejoindre une classe, sans demande en cours).
->reject(fn ($r) => $r->getActionName() === '\\Illuminate\\Routing\\RedirectController'
@ -32,7 +32,7 @@ test('aucune page publique ne charge de police depuis un service externe', funct
$pages = collect(Route::getRoutes())
->filter(fn ($r) => in_array('GET', $r->methods(), true) && $r->getName() !== null && ! str_contains($r->uri(), '{'))
->reject(fn ($r) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/'));
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/') || str_starts_with($r->uri(), 'lti/connexion'));
foreach ($pages as $route) {
$html = $this->get('/'.ltrim($route->uri(), '/'))->getContent();
@ -58,7 +58,7 @@ test('les scripts en ligne des pages publiques sont du JavaScript valide', funct
->filter(fn ($r) => in_array('GET', $r->methods(), true) && $r->getName() !== null && ! str_contains($r->uri(), '{'))
->reject(fn ($r) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
->reject(fn ($r) => $r->getActionName() === '\Illuminate\Routing\RedirectController')
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/'));
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/') || str_starts_with($r->uri(), 'lti/connexion'));
$erreurs = [];
foreach ($pages as $route) {