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
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 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'];
|
||||
|
||||
|
|
|
|||
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\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).
|
||||
|
|
|
|||
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,
|
||||
]);
|
||||
$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(
|
||||
|
|
|
|||
|
|
@ -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
326
portail/composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 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'))
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
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
|
||||
&& ! 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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue