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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

326
portail/composer.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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