ajoute le dossier WebDAV de l'élève (bloc C)
Dossier personnel de l'élève monté comme lecteur réseau : rclone sert le dossier local, l'authentification est déléguée au portail via auth_request Nginx (mêmes identifiants que la connexion), sans nouveau système de comptes. Nettoyé par la purge RGPD comme les autres données d'élève. Corrige au passage un vrai bug d'isolation trouvé en testant avec deux comptes jetables : rewrite injectait la variable posée par auth_request_set, mais rewrite s'exécute dans une phase Nginx antérieure à cette variable, donc toujours vide - un élève pouvait lire le fichier d'un autre en devinant son nom. Corrigé par une location à regex nommée + proxy_pass. Détails : ADR 0008. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cdf79d2823
commit
e549f03e1b
19 changed files with 553 additions and 8 deletions
|
|
@ -9,15 +9,17 @@ use App\Services\Stockage\StockageTravauxEleves;
|
|||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis
|
||||
* 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines,
|
||||
* corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que
|
||||
* les annonces, observations, le chat élève ↔ prof (séparation vitrine/
|
||||
* application, 2026-10-13) et les événements d'examen (2026-10-14). Le
|
||||
* compte du prof est conservé. Le journal ne garde que des identifiants et
|
||||
* des nombres, jamais de nom d'élève.
|
||||
* application, 2026-10-13), les événements d'examen (2026-10-14) et le
|
||||
* dossier WebDAV de chaque élève (bloc C, 2026-10-14). Le compte du prof
|
||||
* est conservé. Le journal ne garde que des identifiants et des nombres,
|
||||
* jamais de nom d'élève.
|
||||
*
|
||||
* Les sauvegardes chiffrées contenant encore ces données expirent d'elles-
|
||||
* mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md).
|
||||
|
|
@ -33,6 +35,7 @@ class PurgerClassesArchivees extends Command
|
|||
$classes = Classe::whereNotNull('archivee_a')
|
||||
->where('archivee_a', '<=', now()->subDays(Classe::DELAI_PURGE_JOURS))
|
||||
->withCount('eleves')
|
||||
->with('eleves:id,classe_id')
|
||||
->get();
|
||||
|
||||
$purgees = 0;
|
||||
|
|
@ -46,13 +49,18 @@ class PurgerClassesArchivees extends Command
|
|||
continue;
|
||||
}
|
||||
|
||||
// Les fichiers d'Object Storage ne sont pas liés par une clé
|
||||
// étrangère : à supprimer nous-mêmes avant la cascade en base.
|
||||
// Les fichiers d'Object Storage et les dossiers WebDAV ne sont
|
||||
// pas liés par une clé étrangère : à supprimer nous-mêmes avant
|
||||
// la cascade en base.
|
||||
RenduDevoir::whereHas('devoir', fn ($q) => $q->where('classe_id', $classe->id))
|
||||
->whereNotNull('fichier_chemin')
|
||||
->pluck('fichier_chemin')
|
||||
->each(fn (string $chemin) => StockageTravauxEleves::supprimer($chemin));
|
||||
|
||||
foreach ($classe->eleves as $eleve) {
|
||||
Storage::disk('webdav')->deleteDirectory((string) $eleve->id);
|
||||
}
|
||||
|
||||
DB::transaction(fn () => $classe->delete());
|
||||
CacheTableauDeBordProf::vider($classe->user_id);
|
||||
Log::info('Purge RGPD : classe supprimée', [
|
||||
|
|
|
|||
25
portail/app/Http/Controllers/MesFichiersController.php
Normal file
25
portail/app/Http/Controllers/MesFichiersController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Eleve;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* « Mes fichiers » (bloc C, CLAUDE.md) : le dossier personnel de l'élève,
|
||||
* montable comme lecteur réseau en WebDAV (voir WebdavAuthController et
|
||||
* ansible/roles/portail/templates/nginx-portail.conf.j2). Pas de liste de
|
||||
* fichiers ici : la page ne fait qu'expliquer comment monter le lecteur,
|
||||
* la navigation dans les fichiers se fait dans l'explorateur du système.
|
||||
*/
|
||||
class MesFichiersController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
/** @var Eleve $eleve */
|
||||
$eleve = Auth::guard('eleve')->user();
|
||||
|
||||
return view('mes-fichiers', ['eleve' => $eleve, 'urlWebdav' => rtrim(config('app.url'), '/').'/webdav/']);
|
||||
}
|
||||
}
|
||||
58
portail/app/Http/Controllers/WebdavAuthController.php
Normal file
58
portail/app/Http/Controllers/WebdavAuthController.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Eleve;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Sous-requête interne de Nginx (auth_request, bloc C, WebDAV des élèves) :
|
||||
* mêmes identifiants que la connexion, jamais de session, jamais appelable
|
||||
* directement depuis l'extérieur (location « internal » de Nginx, voir
|
||||
* ansible/roles/portail/templates/nginx-portail.conf.j2). Répond 200 avec
|
||||
* l'id de l'élève en en-tête (le sous-dossier auquel Nginx le limite), ou
|
||||
* 401 sinon.
|
||||
*/
|
||||
class WebdavAuthController extends Controller
|
||||
{
|
||||
public function verifier(Request $request): Response
|
||||
{
|
||||
$identifiant = $request->getUser();
|
||||
$code = $request->getPassword();
|
||||
|
||||
if ($identifiant === null || $code === null) {
|
||||
return $this->refuser();
|
||||
}
|
||||
|
||||
$cle = 'webdav:'.$identifiant.'|'.$request->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($cle, 5)) {
|
||||
return $this->refuser();
|
||||
}
|
||||
|
||||
$succes = Auth::guard('eleve')->once(['identifiant' => $identifiant, 'password' => $code]);
|
||||
|
||||
if (! $succes) {
|
||||
RateLimiter::hit($cle, 300);
|
||||
|
||||
return $this->refuser();
|
||||
}
|
||||
|
||||
RateLimiter::clear($cle);
|
||||
|
||||
/** @var Eleve $eleve */
|
||||
$eleve = Auth::guard('eleve')->user();
|
||||
Storage::disk('webdav')->makeDirectory((string) $eleve->id);
|
||||
|
||||
return response('', 200)->withHeaders(['X-Eleve-Dossier' => (string) $eleve->id]);
|
||||
}
|
||||
|
||||
private function refuser(): Response
|
||||
{
|
||||
return response('', 401);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,18 @@ return [
|
|||
'report' => false,
|
||||
],
|
||||
|
||||
// Bloc C, WebDAV (2026-10-14) : dossier personnel de l'élève, servi
|
||||
// en WebDAV par rclone (voir ansible/roles/portail/templates/
|
||||
// rclone-webdav.service.j2), un sous-dossier par élève (son id).
|
||||
// Local pour l'instant ; bascule vers S3 possible plus tard sans
|
||||
// changer l'architecture (rclone sait servir les deux).
|
||||
'webdav' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/webdav'),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@
|
|||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.9a7.6 7.6 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7.6 7.6 0 0 0-1.7 1l-2.3-.9-2 3.4L6.6 11a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.9c.5.4 1.1.8 1.7 1l.3 2.5h4l.3-2.5c.6-.2 1.2-.6 1.7-1l2.3.9 2-3.4-2-1.5Z"/></svg>
|
||||
Paramètres
|
||||
</a>
|
||||
@if (! $estProf)
|
||||
<a href="{{ route('mes-fichiers') }}" role="menuitem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3.5" y="7" width="17" height="13" rx="2"/><path d="M3.5 7l1.5-3h6l1.5 3"/></svg>
|
||||
Mes fichiers
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('aide.app') }}" role="menuitem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5"/><path d="M12 17h.01"/></svg>
|
||||
Aide et assistance
|
||||
|
|
|
|||
38
portail/resources/views/mes-fichiers.blade.php
Normal file
38
portail/resources/views/mes-fichiers.blade.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('titre', 'Mes fichiers')
|
||||
|
||||
@section('contenu')
|
||||
<div class="mx-auto max-w-3xl px-5 py-10">
|
||||
<h1 class="text-3xl font-extrabold" style="color:var(--sy-ink)">Mes fichiers</h1>
|
||||
<p class="mt-3 font-semibold" style="color:var(--sy-muted)">
|
||||
Un dossier personnel, à toi seul, que tu peux ouvrir comme un lecteur réseau depuis ton ordinateur — pas besoin de passer par le site pour y déposer ou récupérer un fichier.
|
||||
</p>
|
||||
|
||||
<section class="mt-8 rounded-2xl border p-6" style="border-color:var(--sy-line);background:var(--sy-surface)">
|
||||
<h2 class="text-xl font-extrabold" style="color:var(--sy-ink)">Adresse à utiliser</h2>
|
||||
<p class="mt-3 rounded-xl border px-4 py-3 font-mono text-sm font-semibold" style="border-color:var(--sy-line);background:var(--sy-bg);color:var(--sy-ink)">{{ $urlWebdav }}</p>
|
||||
<p class="mt-3 font-semibold" style="color:var(--sy-muted)">
|
||||
Identifiant et code secret : les mêmes que pour te connecter ({{ $eleve->identifiant }}).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-2xl border p-6" style="border-color:var(--sy-line);background:var(--sy-surface)">
|
||||
<h2 class="text-xl font-extrabold" style="color:var(--sy-ink)">Comment monter le lecteur</h2>
|
||||
<dl class="mt-4 grid gap-4">
|
||||
<div>
|
||||
<dt class="font-bold" style="color:var(--sy-ink)">Windows</dt>
|
||||
<dd class="mt-1 font-semibold" style="color:var(--sy-muted)">Explorateur de fichiers → « Ce PC » → « Ajouter un lecteur réseau » → coller l'adresse ci-dessus.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-bold" style="color:var(--sy-ink)">macOS</dt>
|
||||
<dd class="mt-1 font-semibold" style="color:var(--sy-muted)">Finder → menu « Aller » → « Se connecter au serveur… » → coller l'adresse ci-dessus.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-bold" style="color:var(--sy-ink)">Linux</dt>
|
||||
<dd class="mt-1 font-semibold" style="color:var(--sy-muted)">Gestionnaire de fichiers → « Se connecter à un serveur » → type WebDAV (davs) → coller l'adresse ci-dessus.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -20,6 +20,7 @@ use App\Http\Controllers\InscriptionController;
|
|||
use App\Http\Controllers\MaClasseController;
|
||||
use App\Http\Controllers\MesCoursController;
|
||||
use App\Http\Controllers\MesDevoirsController;
|
||||
use App\Http\Controllers\MesFichiersController;
|
||||
use App\Http\Controllers\MesNotesController;
|
||||
use App\Http\Controllers\ObservationController;
|
||||
use App\Http\Controllers\ParametresController;
|
||||
|
|
@ -36,6 +37,7 @@ use App\Http\Controllers\SessionQuizController;
|
|||
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;
|
||||
|
||||
|
|
@ -157,8 +159,17 @@ Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group
|
|||
// Chantier « vrai cockpit », étape 3 : Devoirs maison.
|
||||
Route::get('/devoirs/{devoir}', [DevoirEleveController::class, 'show'])->name('devoirs.eleve.show');
|
||||
Route::post('/devoirs/{devoir}/rendre', [DevoirEleveController::class, 'rendre'])->name('devoirs.eleve.rendre');
|
||||
|
||||
// Bloc C : dossier personnel en WebDAV.
|
||||
Route::get('/mes-fichiers', [MesFichiersController::class, 'show'])->name('mes-fichiers');
|
||||
});
|
||||
|
||||
// Sous-requête interne de Nginx seulement (location "internal", voir
|
||||
// ansible/roles/portail/templates/nginx-portail.conf.j2) : jamais appelable
|
||||
// depuis l'extérieur. Pas de middleware de session : chaque appel s'identifie
|
||||
// par en-tête HTTP Basic (mêmes identifiants que la connexion élève).
|
||||
Route::get('/interne/webdav-auth', [WebdavAuthController::class, 'verifier'])->name('interne.webdav-auth');
|
||||
|
||||
// --- Espace prof (protégé, session unique) ---
|
||||
Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
||||
Route::get('/mes-classes', [ClasseController::class, 'index'])->name('mes-classes.index');
|
||||
|
|
|
|||
|
|
@ -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(), '_') || $r->uri() === 'up')
|
||||
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || str_starts_with($r->uri(), 'interne/') || $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'
|
||||
|
|
@ -31,7 +31,8 @@ test('chaque page publique s’affiche sans erreur', function () {
|
|||
test('aucune page publique ne charge de police depuis un service externe', function () {
|
||||
$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) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
||||
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/'));
|
||||
|
||||
foreach ($pages as $route) {
|
||||
$html = $this->get('/'.ltrim($route->uri(), '/'))->getContent();
|
||||
|
|
@ -56,7 +57,8 @@ test('les scripts en ligne des pages publiques sont du JavaScript valide', 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) => $r->getActionName() === '\Illuminate\Routing\RedirectController');
|
||||
->reject(fn ($r) => $r->getActionName() === '\Illuminate\Routing\RedirectController')
|
||||
->reject(fn ($r) => str_starts_with($r->uri(), 'interne/'));
|
||||
|
||||
$erreurs = [];
|
||||
foreach ($pages as $route) {
|
||||
|
|
|
|||
69
portail/tests/Feature/WebdavTest.php
Normal file
69
portail/tests/Feature/WebdavTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Eleve;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('webdav');
|
||||
});
|
||||
|
||||
test('la sous-requête Nginx refuse sans identifiants', function () {
|
||||
$this->get(route('interne.webdav-auth'))->assertStatus(401);
|
||||
});
|
||||
|
||||
test('la sous-requête Nginx refuse un code secret incorrect', function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
|
||||
$this->withBasicAuth($eleve->identifiant, 'mauvais-code')
|
||||
->get(route('interne.webdav-auth'))
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
test('la sous-requête Nginx accepte les identifiants de connexion et pose le dossier de l’élève', function () {
|
||||
$eleve = Eleve::factory()->create(['code_secret' => bcrypt('monCode123')]);
|
||||
|
||||
$this->withBasicAuth($eleve->identifiant, 'monCode123')
|
||||
->get(route('interne.webdav-auth'))
|
||||
->assertStatus(200)
|
||||
->assertHeader('X-Eleve-Dossier', (string) $eleve->id);
|
||||
|
||||
Storage::disk('webdav')->assertExists((string) $eleve->id);
|
||||
});
|
||||
|
||||
test('5 échecs de suite bloquent temporairement même avec le bon code juste après', function () {
|
||||
$eleve = Eleve::factory()->create(['code_secret' => bcrypt('monCode123')]);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->withBasicAuth($eleve->identifiant, 'mauvais')
|
||||
->get(route('interne.webdav-auth'))
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
$this->withBasicAuth($eleve->identifiant, 'monCode123')
|
||||
->get(route('interne.webdav-auth'))
|
||||
->assertStatus(401);
|
||||
|
||||
RateLimiter::clear('webdav:'.$eleve->identifiant.'|127.0.0.1');
|
||||
});
|
||||
|
||||
test('un élève connecté voit l’adresse de son dossier WebDAV', function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
|
||||
eleve($this, $eleve)->get(route('mes-fichiers'))
|
||||
->assertOk()
|
||||
->assertSee('/webdav/')
|
||||
->assertSee($eleve->identifiant);
|
||||
});
|
||||
|
||||
test('purger une classe archivée supprime aussi le dossier WebDAV de ses élèves', function () {
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
Storage::disk('webdav')->put($eleve->id.'/photo.jpg', 'contenu');
|
||||
|
||||
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
|
||||
$this->artisan('classes:purger-archivees');
|
||||
|
||||
Storage::disk('webdav')->assertMissing($eleve->id.'/photo.jpg');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue