diff --git a/docs/journal.md b/docs/journal.md index ec510c6..0e27ae1 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1075,3 +1075,26 @@ Deux décisions d'Adam pour finir la liste « avant le pilote ». **Suite** Les 4 points « avant le pilote » sont traités (2 résolus, 2 sciemment reportés avec l'accord d'Adam). Semaine 8 du bloc A : pilote avec une vraie classe — à organiser avec Adam (ce n'est pas une tâche de code). + +## 2026-10-14 — Bloc B, premier chantier : l'API publique (/api/v1) + +Le pilote (semaine 8) attend qu'Adam l'organise ; il demande de continuer en attendant. Premier chantier du bloc B (poste kDrive) listé au CLAUDE.md : une API publique. Avant de coder, j'ai demandé ce qu'elle doit exposer en premier — choix d'Adam : le catalogue en lecture seule (labs, banque de quiz officielle, cours), zéro donnée d'élève. + +**Fait** + +- Laravel Sanctum installé : un jeton Bearer par développeur, créé par un prof depuis `/jetons-api` (le jeton en clair n'est montré qu'une fois, même règle que le code secret d'un élève). Jeton à portée (`ability` Sanctum) `catalogue:lire` seulement — pas d'accès par défaut à autre chose si l'API grandit un jour. +- `routes/api.php`, préfixe `/v1` : `GET /labs`, `/labs/{identifiant}`, `/quiz`, `/quiz/{id}`, `/cours`, `/cours/{identifiant}`. Les quiz exposés sont seulement ceux de la banque officielle (partagés **et** avec un thème) : jamais les questions ni les réponses, seulement les métadonnées (titre, thème, nombre de questions) — un quiz partagé par un prof entre collègues reste interne à l'application. +- Quota par jeton (pas par compte ni par IP) dans Redis : un limiteur nommé `api` (`AppServiceProvider`), 60 requêtes/minute, clé = l'identifiant du jeton Sanctum. `CACHE_STORE=redis` était déjà la config de production (Horizon, sessions) : rien à ajouter côté infra. +- Documentation : spécification OpenAPI 3.0 (`public/openapi/v1.yaml`) et une page publique `/api/docs` (exemples `curl`, réponse type). +- Désactivé la route `/sanctum/csrf-cookie` (`config/sanctum.php`, `routes => false`) : elle n'a pas de sens ici, l'API est en jeton Bearer seulement, jamais en cookie de SPA — sa présence faisait d'ailleurs échouer le test qui vérifie que toute page publique répond 200 (elle répond 204, une page sans contenu, ce qui est correct mais pas un code que ce test attendait). +- 9 nouveaux tests Pest (109 au total) : pas de jeton → 401, jeton sans la bonne portée → 403, jeton révoqué → 401, contenu correct de chaque point d'accès, un quiz privé ou sans thème renvoie 404, les en-têtes de limite de débit sont présents, un prof crée/voit une fois/révoque son jeton, ne peut pas révoquer celui d'un autre prof. + +**Comprendre** + +Le choix qui limite le plus le risque ici : exposer seulement ce qui est déjà pensé comme public ou quasi-public dans l'application (le catalogue), jamais une donnée d'élève, même en lecture. Ça permet de vraiment montrer le savoir-faire attendu pour le poste kDrive (jeton, versionnage, quotas, documentation, tests) sans ouvrir de nouvelle surface RGPD à surveiller. + +**Vérifié sur le serveur réel** (prof jetable, supprimé ensuite) : jeton créé depuis `/jetons-api`, affiché une seule fois ; `GET /api/v1/labs` sans jeton → 401, avec jeton → 200 avec les en-têtes `X-RateLimit-*` (60/59) et les vraies compétences du lab.yaml ; `/api/v1/cours` liste sans le contenu, `/api/v1/cours/{id}` avec ; `/api/v1/quiz` renvoie les 8 quiz de la banque officielle avec leur thème, jamais leurs questions ; lab inconnu → 404 ; jeton révoqué depuis la page → 401 immédiatement. `/api/docs` et `/openapi/v1.yaml` répondent. Rien touché chez Adam. + +**Suite** + +Prochain chantier du bloc B : cache Redis sur les lectures lourdes, avec mesure avant/après dans Grafana. diff --git a/portail/app/Http/Controllers/Api/V1/CoursController.php b/portail/app/Http/Controllers/Api/V1/CoursController.php new file mode 100644 index 0000000..7836568 --- /dev/null +++ b/portail/app/Http/Controllers/Api/V1/CoursController.php @@ -0,0 +1,24 @@ +get()); + } + + public function show(Cours $cours): CoursResource + { + return new CoursResource($cours); + } +} diff --git a/portail/app/Http/Controllers/Api/V1/LabController.php b/portail/app/Http/Controllers/Api/V1/LabController.php new file mode 100644 index 0000000..aab9b46 --- /dev/null +++ b/portail/app/Http/Controllers/Api/V1/LabController.php @@ -0,0 +1,24 @@ +get()); + } + + public function show(Lab $lab): LabResource + { + return new LabResource($lab); + } +} diff --git a/portail/app/Http/Controllers/Api/V1/QuizController.php b/portail/app/Http/Controllers/Api/V1/QuizController.php new file mode 100644 index 0000000..4b8bbb9 --- /dev/null +++ b/portail/app/Http/Controllers/Api/V1/QuizController.php @@ -0,0 +1,30 @@ +whereNotNull('theme')->withCount('questions')->orderBy('theme')->get() + ); + } + + public function show(Quiz $quiz): QuizResource + { + abort_unless($quiz->partage && $quiz->theme !== null, 404); + + return new QuizResource($quiz->loadCount('questions')); + } +} diff --git a/portail/app/Http/Controllers/TokenApiController.php b/portail/app/Http/Controllers/TokenApiController.php new file mode 100644 index 0000000..5cf41a1 --- /dev/null +++ b/portail/app/Http/Controllers/TokenApiController.php @@ -0,0 +1,45 @@ +user(); + + return view('jetons-api', ['jetons' => $prof->tokens()->latest()->get()]); + } + + public function store(Request $request): RedirectResponse + { + $donnees = $request->validate(['nom' => ['required', 'string', 'max:80']]); + + $prof = Auth::guard('web')->user(); + $jeton = $prof->createToken($donnees['nom'], ['catalogue:lire']); + + return redirect()->route('jetons-api.index')->with('jeton_cree', $jeton->plainTextToken); + } + + public function destroy(PersonalAccessToken $jeton): RedirectResponse + { + $prof = Auth::guard('web')->user(); + abort_if($jeton->tokenable_id !== $prof->id || $jeton->tokenable_type !== $prof::class, 403); + + $jeton->delete(); + + return redirect()->route('jetons-api.index'); + } +} diff --git a/portail/app/Http/Resources/Api/V1/CoursResource.php b/portail/app/Http/Resources/Api/V1/CoursResource.php new file mode 100644 index 0000000..9185904 --- /dev/null +++ b/portail/app/Http/Resources/Api/V1/CoursResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'identifiant' => $this->identifiant, + 'titre' => $this->titre, + 'contenu_markdown' => $this->when($request->routeIs('api.v1.cours.show'), $this->contenu_markdown), + ]; + } +} diff --git a/portail/app/Http/Resources/Api/V1/LabResource.php b/portail/app/Http/Resources/Api/V1/LabResource.php new file mode 100644 index 0000000..aad144f --- /dev/null +++ b/portail/app/Http/Resources/Api/V1/LabResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'identifiant' => $this->identifiant, + 'titre' => $this->titre, + 'niveau' => $this->niveau, + 'duree_min' => $this->duree_min, + 'competences' => $this->competences, + ]; + } +} diff --git a/portail/app/Http/Resources/Api/V1/QuizResource.php b/portail/app/Http/Resources/Api/V1/QuizResource.php new file mode 100644 index 0000000..f438366 --- /dev/null +++ b/portail/app/Http/Resources/Api/V1/QuizResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'titre' => $this->titre, + 'theme' => $this->theme, + 'questions_count' => $this->whenCounted('questions'), + ]; + } +} diff --git a/portail/app/Models/User.php b/portail/app/Models/User.php index 81e6b2e..9478ffd 100644 --- a/portail/app/Models/User.php +++ b/portail/app/Models/User.php @@ -13,13 +13,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password', 'etablissement_id'])] #[Hidden(['password', 'remember_token', 'jeton_session_actif'])] class User extends Authenticatable { /** @use HasFactory */ - use GerePreferences, GereSessionUnique, HasFactory, Notifiable; + use GerePreferences, GereSessionUnique, HasApiTokens, HasFactory, Notifiable; /** * Get the attributes that should be cast. diff --git a/portail/app/Providers/AppServiceProvider.php b/portail/app/Providers/AppServiceProvider.php index 96f7baa..f0aa526 100644 --- a/portail/app/Providers/AppServiceProvider.php +++ b/portail/app/Providers/AppServiceProvider.php @@ -5,7 +5,10 @@ namespace App\Providers; use App\Models\Eleve; use App\Models\User; use App\Services\CompteurDevoirsEnAttente; +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; @@ -39,5 +42,13 @@ class AppServiceProvider extends ServiceProvider 'badgeMesDevoirs' => $compte instanceof Eleve ? CompteurDevoirsEnAttente::pour($compte) : 0, ]); }); + + // API publique (bloc B) : quota par jeton Sanctum, pas par compte ni + // par IP — un même prof avec deux jetons a deux quotas séparés. + // CACHE_STORE=redis en production (voir CLAUDE.md : « Redis pour les + // quotas »), stocké dans config/cache.php -> 'database' en local/test. + RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by( + $request->user()?->currentAccessToken()?->id ?? $request->ip() + )); } } diff --git a/portail/bootstrap/app.php b/portail/bootstrap/app.php index d4d21bc..4d126be 100644 --- a/portail/bootstrap/app.php +++ b/portail/bootstrap/app.php @@ -7,10 +7,13 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Laravel\Sanctum\Http\Middleware\CheckAbilities; +use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) @@ -18,6 +21,8 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'session.unique' => VerifieSessionUnique::class, 'maj.activite' => MetAJourActivite::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, ]); $middleware->web(append: [PolitiqueSecuriteContenu::class]); }) diff --git a/portail/composer.json b/portail/composer.json index ace2551..b9cae3f 100644 --- a/portail/composer.json +++ b/portail/composer.json @@ -10,6 +10,7 @@ "bacon/bacon-qr-code": "^3.1", "laravel/framework": "^13.17", "laravel/horizon": "^5.50", + "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", "symfony/yaml": "^7.4" }, diff --git a/portail/composer.lock b/portail/composer.lock index d132652..a5c78a9 100644 --- a/portail/composer.lock +++ b/portail/composer.lock @@ -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": "0d410ce6600351e37dc21a4c2f27fe2b", + "content-hash": "4f63b39390b44505487b3c5999569f85", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1532,6 +1532,69 @@ }, "time": "2026-10-06T02:25:49+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/sentinel", "version": "v1.1.0", diff --git a/portail/config/sanctum.php b/portail/config/sanctum.php new file mode 100644 index 0000000..ed3e808 --- /dev/null +++ b/portail/config/sanctum.php @@ -0,0 +1,100 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Routes Sanctum (bloc B, 2026-10-14) + |-------------------------------------------------------------------------- + | + | Pas de front SPA ici : l'API publique est en jeton Bearer seulement, + | jamais en cookie. La route /sanctum/csrf-cookie ne sert à rien dans + | ce cas et n'a pas à être publique. + | + */ + + 'routes' => false, + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/portail/database/migrations/2026_10_13_230833_create_personal_access_tokens_table.php b/portail/database/migrations/2026_10_13_230833_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/portail/database/migrations/2026_10_13_230833_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/portail/public/openapi/v1.yaml b/portail/public/openapi/v1.yaml new file mode 100644 index 0000000..8e297ef --- /dev/null +++ b/portail/public/openapi/v1.yaml @@ -0,0 +1,175 @@ +openapi: 3.0.3 +info: + title: Syllabus — API publique + version: "1.0" + description: > + Catalogue en lecture seule (labs, banque de quiz officielle, cours). + Aucune donnée d'élève. Jeton créé depuis /jetons-api (compte prof). + contact: + email: adam@belkacemi.me +servers: + - url: https://syllabuslab.fr/api/v1 +security: + - bearerAuth: [] +paths: + /labs: + get: + summary: Liste des labs du catalogue + operationId: listerLabs + tags: [Labs] + responses: + "200": + description: Liste des labs + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Lab" + /labs/{identifiant}: + get: + summary: Détail d'un lab + operationId: lireLab + tags: [Labs] + parameters: + - $ref: "#/components/parameters/IdentifiantLab" + responses: + "200": + description: Le lab + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Lab" + "404": + description: Lab introuvable + /quiz: + get: + summary: Liste des quiz de la banque officielle + operationId: listerQuiz + tags: [Quiz] + responses: + "200": + description: Liste des quiz (métadonnées seulement, jamais les questions) + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Quiz" + /quiz/{id}: + get: + summary: Détail d'un quiz (métadonnées) + operationId: lireQuiz + tags: [Quiz] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Le quiz + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Quiz" + "404": + description: Quiz introuvable ou non partagé + /cours: + get: + summary: Liste des cours du catalogue + operationId: listerCours + tags: [Cours] + responses: + "200": + description: Liste des cours (sans le contenu complet) + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/CoursResume" + /cours/{identifiant}: + get: + summary: Détail d'un cours, avec son contenu + operationId: lireCours + tags: [Cours] + parameters: + - name: identifiant + in: path + required: true + schema: + type: string + example: 08-deux-lan-routeur + responses: + "200": + description: Le cours + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Cours" + "404": + description: Cours introuvable +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Jeton créé depuis /jetons-api. 60 requêtes par minute et par jeton. + parameters: + IdentifiantLab: + name: identifiant + in: path + required: true + schema: + type: string + example: 08-deux-lan-routeur + schemas: + Lab: + type: object + properties: + identifiant: { type: string, example: 08-deux-lan-routeur } + titre: { type: string, example: Deux LAN et un routeur } + niveau: { type: string, nullable: true, example: decouverte } + duree_min: { type: integer, nullable: true, example: 45 } + competences: + type: array + items: { type: string } + example: [adressage-ipv4, routage-statique] + Quiz: + type: object + properties: + id: { type: integer, example: 7 } + titre: { type: string, example: Un serveur web et son client } + theme: { type: string, nullable: true, example: SRV } + questions_count: { type: integer, example: 6 } + CoursResume: + type: object + properties: + identifiant: { type: string, example: 08-deux-lan-routeur } + titre: { type: string, example: Deux LAN et un routeur } + Cours: + allOf: + - $ref: "#/components/schemas/CoursResume" + - type: object + properties: + contenu_markdown: { type: string } diff --git a/portail/resources/views/api-docs.blade.php b/portail/resources/views/api-docs.blade.php new file mode 100644 index 0000000..e0d515e --- /dev/null +++ b/portail/resources/views/api-docs.blade.php @@ -0,0 +1,64 @@ +@extends('layouts.vitrine') + +@section('titre', "API publique") + +@section('contenu') +
+

API publique Syllabus

+

Catalogue en lecture seule : labs, banque de quiz officielle, cours. Aucune donnée d'élève.

+ +
+

Authentification

+

Crée un jeton depuis un compte prof : connexion puis « Mon profil → Jetons de l'API ». Envoie-le dans l'en-tête :

+
Authorization: Bearer <ton-jeton>
+

60 requêtes par minute et par jeton (en-têtes X-RateLimit-* sur chaque réponse).

+
+ +
+

Points d'accès

+
+
+

GET /api/v1/labs

+

Liste des labs du catalogue.

+
+
+

GET /api/v1/labs/{identifiant}

+

Détail d'un lab (ex. 08-deux-lan-routeur).

+
+
+

GET /api/v1/quiz

+

Liste des quiz de la banque officielle (titre, thème, nombre de questions — jamais les questions ni les réponses).

+
+
+

GET /api/v1/quiz/{id}

+

Détail d'un quiz (métadonnées).

+
+
+

GET /api/v1/cours

+

Liste des cours du catalogue.

+
+
+

GET /api/v1/cours/{identifiant}

+

Détail d'un cours, avec son contenu complet.

+
+
+
+ +
+

Exemple

+
curl -H "Authorization: Bearer <ton-jeton>" \
+  https://syllabuslab.fr/api/v1/labs/08-deux-lan-routeur
+
{
+  "data": {
+    "identifiant": "08-deux-lan-routeur",
+    "titre": "Deux LAN et un routeur",
+    "niveau": "decouverte",
+    "duree_min": 45,
+    "competences": ["adressage-ipv4", "routage-statique"]
+  }
+}
+
+ +

Spécification OpenAPI (v1.yaml) →

+
+@endsection diff --git a/portail/resources/views/jetons-api.blade.php b/portail/resources/views/jetons-api.blade.php new file mode 100644 index 0000000..a28d053 --- /dev/null +++ b/portail/resources/views/jetons-api.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.app') + +@section('titre', 'Jetons de l\'API') + +@section('contenu') +
+

← Mon profil

+

Jetons de l'API publique

+

Un jeton donne accès en lecture au catalogue (labs, banque de quiz officielle, cours) via /api/v1. 60 requêtes par minute et par jeton.

+ + @if (session('jeton_cree')) +
+

Ton jeton (copie-le maintenant, il ne sera plus jamais affiché) :

+

{{ session('jeton_cree') }}

+

À utiliser dans l'en-tête Authorization: Bearer <jeton>.

+
+ @endif + +
+ @csrf + + +
+ +
+

Jetons actifs

+ @if ($jetons->isEmpty()) +

Aucun jeton pour l'instant.

+ @else +
    + @foreach ($jetons as $jeton) +
  • + {{ $jeton->name }} + · créé le {{ $jeton->created_at->translatedFormat('d/m/Y') }}@if($jeton->last_used_at) · utilisé le {{ $jeton->last_used_at->translatedFormat('d/m/Y') }}@endif + +
    + @csrf + +
    +
  • + @endforeach +
+ @endif +
+ +

Documentation : /api/docs.

+
+@endsection diff --git a/portail/resources/views/profil.blade.php b/portail/resources/views/profil.blade.php index cd17b52..7319989 100644 --- a/portail/resources/views/profil.blade.php +++ b/portail/resources/views/profil.blade.php @@ -39,5 +39,9 @@ + + @if ($role === 'prof') +

Jetons de l'API publique →

+ @endif @endsection diff --git a/portail/routes/api.php b/portail/routes/api.php new file mode 100644 index 0000000..ddb780c --- /dev/null +++ b/portail/routes/api.php @@ -0,0 +1,24 @@ +middleware(['auth:sanctum', 'throttle:api', 'abilities:catalogue:lire'])->name('api.v1.')->group(function () { + Route::get('/labs', [LabController::class, 'index'])->name('labs.index'); + Route::get('/labs/{lab:identifiant}', [LabController::class, 'show'])->name('labs.show'); + + Route::get('/quiz', [QuizController::class, 'index'])->name('quiz.index'); + Route::get('/quiz/{quiz}', [QuizController::class, 'show'])->name('quiz.show'); + + Route::get('/cours', [CoursController::class, 'index'])->name('cours.index'); + Route::get('/cours/{cours:identifiant}', [CoursController::class, 'show'])->name('cours.show'); +}); diff --git a/portail/routes/web.php b/portail/routes/web.php index 1570ece..32995d6 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -34,6 +34,7 @@ use App\Http\Controllers\SeanceController; use App\Http\Controllers\SeancesEnDirectController; use App\Http\Controllers\SessionQuizController; use App\Http\Controllers\SignalementController; +use App\Http\Controllers\TokenApiController; use App\Http\Controllers\VisioController; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Route; @@ -82,6 +83,7 @@ Route::view('/sujets/creer', 'sujet-creer')->name('sujet.creer'); Route::view('/messagerie', 'messagerie')->name('messagerie'); Route::view('/exports', 'export-ent')->name('export.ent'); Route::view('/aide', 'aide')->name('aide'); +Route::view('/api/docs', 'api-docs')->name('api.docs'); Route::view('/academie', 'academie')->name('academie'); Route::view('/paiement', 'paiement')->name('paiement'); Route::view('/paiement/confirme', 'paiement-ok')->name('paiement.ok'); @@ -219,4 +221,9 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::get('/chat', [ChatProfController::class, 'index'])->name('chat.prof.index'); Route::get('/chat/{fil}/etat', [ChatProfController::class, 'etat'])->name('chat.prof.etat'); Route::post('/chat/{fil}/envoyer', [ChatProfController::class, 'envoyer'])->middleware('throttle:30,1')->name('chat.prof.envoyer'); + + // API publique (bloc B) : jetons Sanctum du prof. + Route::get('/jetons-api', [TokenApiController::class, 'index'])->name('jetons-api.index'); + Route::post('/jetons-api', [TokenApiController::class, 'store'])->name('jetons-api.store'); + Route::post('/jetons-api/{jeton}/revoquer', [TokenApiController::class, 'destroy'])->name('jetons-api.destroy'); }); diff --git a/portail/tests/Feature/ApiV1Test.php b/portail/tests/Feature/ApiV1Test.php new file mode 100644 index 0000000..ee35e10 --- /dev/null +++ b/portail/tests/Feature/ApiV1Test.php @@ -0,0 +1,113 @@ +createToken('test', $abilities)->plainTextToken; +} + +test('aucune requête sans jeton', function () { + $this->getJson('/api/v1/labs')->assertUnauthorized(); +}); + +test("un jeton sans l'ability catalogue:lire est refusé", function () { + $jeton = jetonApi(User::factory()->create(), []); + + $this->withToken($jeton)->getJson('/api/v1/labs')->assertForbidden(); +}); + +test('un jeton révoqué ne fonctionne plus', function () { + $prof = User::factory()->create(); + $jeton = jetonApi($prof); + $prof->tokens()->delete(); + + $this->withToken($jeton)->getJson('/api/v1/labs')->assertUnauthorized(); +}); + +test('liste et détail des labs', function () { + $jeton = jetonApi(User::factory()->create()); + Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]); + + $this->withToken($jeton)->getJson('/api/v1/labs') + ->assertOk() + ->assertJsonFragment(['identifiant' => '08-deux-lan-routeur']); + + $this->withToken($jeton)->getJson('/api/v1/labs/08-deux-lan-routeur') + ->assertOk() + ->assertJson(['data' => ['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]]); + + $this->withToken($jeton)->getJson('/api/v1/labs/inconnu')->assertNotFound(); +}); + +test('la liste des quiz ne montre que la banque officielle (partagés, avec thème), jamais les questions', function () { + $jeton = jetonApi(User::factory()->create()); + $classe = Classe::factory()->create(); + + $officiel = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Officiel', 'theme' => 'NET', 'partage' => true]); + $officiel->questions()->create(['enonce' => 'Une question', 'type' => 'vrai_faux', 'ordre' => 0, 'reponse_vrai_faux' => true]); + Quiz::create(['classe_id' => $classe->id, 'titre' => 'Partagé sans thème', 'theme' => null, 'partage' => true]); + Quiz::create(['classe_id' => $classe->id, 'titre' => 'Privé', 'theme' => 'NET', 'partage' => false]); + + $reponse = $this->withToken($jeton)->getJson('/api/v1/quiz')->assertOk(); + $titres = collect($reponse->json('data'))->pluck('titre'); + + expect($titres->all())->toBe(['Officiel']) + ->and($reponse->json('data.0'))->not->toHaveKey('questions') + ->and($reponse->json('data.0.questions_count'))->toBe(1); + + $this->withToken($jeton)->getJson("/api/v1/quiz/{$officiel->id}")->assertOk(); + + $prive = Quiz::where('titre', 'Privé')->first(); + $this->withToken($jeton)->getJson("/api/v1/quiz/{$prive->id}")->assertNotFound(); +}); + +test('liste et détail des cours : le contenu complet seulement sur le détail', function () { + $jeton = jetonApi(User::factory()->create()); + Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => '# Cours']); + + $this->withToken($jeton)->getJson('/api/v1/cours') + ->assertOk() + ->assertJsonMissing(['contenu_markdown' => '# Cours']); + + $this->withToken($jeton)->getJson('/api/v1/cours/08-deux-lan-routeur') + ->assertOk() + ->assertJson(['data' => ['contenu_markdown' => '# Cours']]); +}); + +test('chaque réponse porte les en-têtes de limite de débit', function () { + $jeton = jetonApi(User::factory()->create()); + + $this->withToken($jeton)->getJson('/api/v1/labs') + ->assertHeader('X-RateLimit-Limit', 60); +}); + +test('un prof crée un jeton, le voit en clair une seule fois, le révoque', function () { + $prof = User::factory()->create(); + + $page = prof($this, $prof)->post(route('jetons-api.store'), ['nom' => 'Mon script'])->assertRedirect(); + $page = prof($this, $prof)->get(route('jetons-api.index')); + $page->assertOk()->assertSee('Mon script'); + + expect($prof->tokens()->count())->toBe(1); + $jeton = $prof->tokens()->first(); + + prof($this, $prof)->get(route('jetons-api.index'))->assertDontSee($jeton->token); + + prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertRedirect(); + expect($prof->tokens()->count())->toBe(0); +}); + +test("un prof ne peut pas révoquer le jeton d'un autre prof", function () { + $prof = User::factory()->create(); + $autreProf = User::factory()->create(); + jetonApi($autreProf); + $jeton = $autreProf->tokens()->first(); + + prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertForbidden(); + expect($autreProf->tokens()->count())->toBe(1); +});