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:
parent
777dd1cbc8
commit
91d7cde4c1
27 changed files with 1293 additions and 9 deletions
76
docs/adr/0010-lti-moodle-comme-sixieme-facon-de-rejoindre.md
Normal file
76
docs/adr/0010-lti-moodle-comme-sixieme-facon-de-rejoindre.md
Normal 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.
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -1274,3 +1274,27 @@ Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, me
|
||||||
**Suite**
|
**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.
|
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.
|
||||||
|
|
|
||||||
74
docs/runbooks/lti-moodle.md
Normal file
74
docs/runbooks/lti-moodle.md
Normal 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.
|
||||||
38
portail/app/Console/Commands/EnregistrerDeploiementLti.php
Normal file
38
portail/app/Console/Commands/EnregistrerDeploiementLti.php
Normal 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é.');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
portail/app/Console/Commands/EnregistrerPlateformeLti.php
Normal file
59
portail/app/Console/Commands/EnregistrerPlateformeLti.php
Normal 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>");
|
||||||
|
}
|
||||||
|
}
|
||||||
26
portail/app/Http/Controllers/LtiClesController.php
Normal file
26
portail/app/Http/Controllers/LtiClesController.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
37
portail/app/Http/Controllers/LtiConnexionController.php
Normal file
37
portail/app/Http/Controllers/LtiConnexionController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
portail/app/Http/Controllers/LtiLancementController.php
Normal file
98
portail/app/Http/Controllers/LtiLancementController.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ class Eleve extends Model implements AuthenticatableContract
|
||||||
/** @use HasFactory<EleveFactory> */
|
/** @use HasFactory<EleveFactory> */
|
||||||
use Authenticatable, GerePreferences, GereSessionUnique, HasFactory;
|
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'];
|
protected $hidden = ['code_secret', 'jeton_session_actif', 'jeton_qr'];
|
||||||
|
|
||||||
|
|
|
||||||
17
portail/app/Models/LtiDeploiement.php
Normal file
17
portail/app/Models/LtiDeploiement.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
portail/app/Models/LtiPlateforme.php
Normal file
22
portail/app/Models/LtiPlateforme.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,22 @@ namespace App\Providers;
|
||||||
use App\Models\Eleve;
|
use App\Models\Eleve;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\CompteurDevoirsEnAttente;
|
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\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
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
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -19,7 +29,12 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
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
|
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 à
|
// Coquille de l'application (layouts/app) : variables communes à
|
||||||
// la barre du haut et à la barre latérale, pour qu'aucun contrôleur
|
// 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).
|
// n'ait à les repasser (séparation vitrine/application, 2026-10-13).
|
||||||
|
|
|
||||||
69
portail/app/Services/Lti/CacheLti.php
Normal file
69
portail/app/Services/Lti/CacheLti.php
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
28
portail/app/Services/Lti/CookieLti.php
Normal file
28
portail/app/Services/Lti/CookieLti.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
61
portail/app/Services/Lti/DatabaseLti.php
Normal file
61
portail/app/Services/Lti/DatabaseLti.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
'ability' => CheckForAnyAbility::class,
|
'ability' => CheckForAnyAbility::class,
|
||||||
]);
|
]);
|
||||||
$middleware->web(append: [PolitiqueSecuriteContenu::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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
|
"packbackbooks/lti-1p3-tool": "^6.4",
|
||||||
"symfony/yaml": "^7.4"
|
"symfony/yaml": "^7.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
|
||||||
326
portail/composer.lock
generated
326
portail/composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "6f5b54180f0d3c00c4708c345a8ac918",
|
"content-hash": "d5437c093e6b2b4deeaffebf07e46fac",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -921,6 +921,80 @@
|
||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"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",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
|
|
@ -3278,6 +3352,136 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-16T23:10:27+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
|
|
@ -3353,6 +3557,126 @@
|
||||||
],
|
],
|
||||||
"time": "2026-08-24T00:54:40+00:00"
|
"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",
|
"name": "psr/cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
13
portail/resources/views/lti/erreur.blade.php
Normal file
13
portail/resources/views/lti/erreur.blade.php
Normal 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>
|
||||||
|
|
@ -248,6 +248,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-self-center">{!! \App\Support\QrCode::svg(route('rejoindre', ['code' => $classe->code_classe])) !!}</div>
|
<div class="justify-self-center">{!! \App\Support\QrCode::svg(route('rejoindre', ['code' => $classe->code_classe])) !!}</div>
|
||||||
</section>
|
</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
|
@endunless
|
||||||
|
|
||||||
@if (session('fiches'))
|
@if (session('fiches'))
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ use App\Http\Controllers\EleveTableauDeBordController;
|
||||||
use App\Http\Controllers\EmploiDuTempsController;
|
use App\Http\Controllers\EmploiDuTempsController;
|
||||||
use App\Http\Controllers\EvenementExamenController;
|
use App\Http\Controllers\EvenementExamenController;
|
||||||
use App\Http\Controllers\InscriptionController;
|
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\MaClasseController;
|
||||||
use App\Http\Controllers\MesCoursController;
|
use App\Http\Controllers\MesCoursController;
|
||||||
use App\Http\Controllers\MesDevoirsController;
|
use App\Http\Controllers\MesDevoirsController;
|
||||||
|
|
@ -39,7 +42,6 @@ use App\Http\Controllers\SignalementController;
|
||||||
use App\Http\Controllers\TokenApiController;
|
use App\Http\Controllers\TokenApiController;
|
||||||
use App\Http\Controllers\VisioController;
|
use App\Http\Controllers\VisioController;
|
||||||
use App\Http\Controllers\WebdavAuthController;
|
use App\Http\Controllers\WebdavAuthController;
|
||||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::view('/', 'accueil')->name('accueil');
|
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('/classes/suivi', 'classes.suivi')->name('suivi');
|
||||||
Route::view('/examen', 'examen')->name('examen');
|
Route::view('/examen', 'examen')->name('examen');
|
||||||
// À brancher sur l'envoi d'e-mail (Mail::to('adam@belkacemi.me')->send(...)).
|
// À 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)
|
Route::post('/csp-rapport', RapportCspController::class)
|
||||||
->withoutMiddleware(ValidateCsrfToken::class)
|
|
||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('csp.rapport');
|
->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::post('/contact', fn () => back()->with('contact_ok', true))->name('contact');
|
||||||
Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites');
|
Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites');
|
||||||
Route::view('/legal', 'legal')->name('legal');
|
Route::view('/legal', 'legal')->name('legal');
|
||||||
|
|
|
||||||
167
portail/tests/Feature/LtiTest.php
Normal file
167
portail/tests/Feature/LtiTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,7 @@ test('chaque page publique s’affiche sans erreur', function () {
|
||||||
&& $r->getName() !== null
|
&& $r->getName() !== null
|
||||||
&& ! str_contains($r->uri(), '{')
|
&& ! str_contains($r->uri(), '{')
|
||||||
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
&& ! 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
|
// Redirections voulues (ex. /statut vers le site de statut ; pages de
|
||||||
// suivi d'une demande pour rejoindre une classe, sans demande en cours).
|
// suivi d'une demande pour rejoindre une classe, sans demande en cours).
|
||||||
->reject(fn ($r) => $r->getActionName() === '\\Illuminate\\Routing\\RedirectController'
|
->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())
|
$pages = collect(Route::getRoutes())
|
||||||
->filter(fn ($r) => in_array('GET', $r->methods(), true) && $r->getName() !== null && ! str_contains($r->uri(), '{'))
|
->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) => 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) {
|
foreach ($pages as $route) {
|
||||||
$html = $this->get('/'.ltrim($route->uri(), '/'))->getContent();
|
$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(), '{'))
|
->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) => 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) => $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 = [];
|
$erreurs = [];
|
||||||
foreach ($pages as $route) {
|
foreach ($pages as $route) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue