168 lines
6.4 KiB
PHP
168 lines
6.4 KiB
PHP
|
|
<?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);
|
|||
|
|
});
|