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,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) {