From 49959a364bb31e6d66fb56b578fa29cd6c842814 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 23:05:07 +0000 Subject: [PATCH] Restrict certificate HTML show to owner or course updater Require auth on the show route and drop guest access via course completion or signed URLs. Guests are redirected; only the certificate subject or a user who can update the course may view HTML. Co-authored-by: Scott Grayson --- routes/web.php | 3 +- .../Controllers/CertificateController.php | 5 +- tests/Feature/CertificateControllerTest.php | 121 ++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/routes/web.php b/routes/web.php index 82e222ac..3b7e56eb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,7 +21,8 @@ ->name('filament-lms::certificates.download') ->middleware('auth'); Route::get('lms/certificates/{course}/{user}', [CertificateController::class, 'show']) - ->name('filament-lms::certificates.show'); + ->name('filament-lms::certificates.show') + ->middleware('auth'); Route::get('lms/scorm-package/{document}/{entry?}', [ScormPackageController::class, 'show']) ->where('entry', '.*') diff --git a/src/Http/Controllers/CertificateController.php b/src/Http/Controllers/CertificateController.php index 1005cd18..a57b52b6 100644 --- a/src/Http/Controllers/CertificateController.php +++ b/src/Http/Controllers/CertificateController.php @@ -31,9 +31,8 @@ public function show($courseId, $userId): View throw new InvalidArgumentException('User model must implement Authenticatable contract'); } - if (! request()->hasValidSignature() && - ! $course->completedByUserAt($userId) && - ! Auth::user()?->can('update', $course)) { + // HTML certificate is private: owner or course updater only (no guest / completion-only access). + if (Auth::id() != $userId && ! Auth::user()?->can('update', $course)) { abort(403); } diff --git a/tests/Feature/CertificateControllerTest.php b/tests/Feature/CertificateControllerTest.php index 4dd13e68..09f055ee 100644 --- a/tests/Feature/CertificateControllerTest.php +++ b/tests/Feature/CertificateControllerTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Route; +use Tapp\FilamentCertificateBuilder\Actions\ResolveSampleCertificateTokensAction; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Support\CertificateBuilder; use Tapp\FilamentLms\Tests\TestUser; @@ -10,6 +13,29 @@ config([ 'filament-lms.user_model' => TestUser::class, 'auth.providers.users.model' => TestUser::class, + 'certificate-builder.token_sets.course' => [ + 'label' => 'Course', + 'resolver' => ResolveSampleCertificateTokensAction::class, + 'tokens' => [ + 'recipient_name' => [ + 'label' => 'Recipient name', + 'sample' => 'Jane Doe', + ], + 'course_name' => [ + 'label' => 'Course name', + 'sample' => 'Sample Course', + ], + 'date_range' => [ + 'label' => 'Date range', + 'sample' => 'January 15th - March 15th 2026', + ], + ], + 'default_copy' => [ + 'certifying_line' => 'This certifies that', + 'completed_line' => 'has successfully completed', + 'description' => '', + ], + ], ]); }); @@ -31,6 +57,93 @@ ->assertNotFound(); }); +it('redirects guests away from the certificate show page', function () { + Route::get('/login', fn () => 'login')->name('login'); + + $user = TestUser::query()->create([ + 'name' => 'Guest Target', + 'email' => 'guest-target-cert@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create([ + 'name' => 'Guest Blocked Course', + ]); + + $course->users()->attach($user->id, ['completed_at' => now()]); + + $this->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id])) + ->assertRedirect(route('login')); +}); + +it('allows the certificate owner to view the HTML certificate', function () { + $user = TestUser::query()->create([ + 'name' => 'Owner Learner', + 'email' => 'owner-cert@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create([ + 'name' => 'Owner Course', + ]); + + $course->users()->attach($user->id, ['completed_at' => now()]); + + $this->actingAs($user) + ->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id])) + ->assertOk(); +}); + +it('allows an admin who can update the course to preview another users certificate', function () { + Gate::policy(Course::class, CertificateShowCourseUpdatePolicy::class); + + $learner = TestUser::query()->create([ + 'name' => 'Learner', + 'email' => 'learner-cert@example.com', + 'password' => bcrypt('password'), + ]); + $admin = TestUser::query()->create([ + 'name' => 'Allowed Admin', + 'email' => 'allowed@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create([ + 'name' => 'Admin Preview Course', + ]); + + $course->users()->attach($learner->id, ['completed_at' => now()]); + + $this->actingAs($admin) + ->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $learner->id])) + ->assertOk(); +}); + +it('forbids another authenticated user from viewing someone elses certificate', function () { + Gate::policy(Course::class, CertificateShowCourseUpdatePolicy::class); + + $owner = TestUser::query()->create([ + 'name' => 'Certificate Owner', + 'email' => 'owner-other-cert@example.com', + 'password' => bcrypt('password'), + ]); + $stranger = TestUser::query()->create([ + 'name' => 'Stranger', + 'email' => 'stranger-cert@example.com', + 'password' => bcrypt('password'), + ]); + + $course = Course::factory()->create([ + 'name' => 'Private Certificate Course', + ]); + + $course->users()->attach($owner->id, ['completed_at' => now()]); + + $this->actingAs($stranger) + ->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $owner->id])) + ->assertForbidden(); +}); + it('forbids download when the user has not completed the course', function () { $user = TestUser::query()->create([ 'name' => 'Pat Learner', @@ -57,3 +170,11 @@ ->and(CertificateBuilder::canCreateTemplate($user, $course))->toBeTrue() ->and(CertificateBuilder::canEditTemplate($user, $course))->toBeFalse(); }); + +class CertificateShowCourseUpdatePolicy +{ + public function update(object $user, Course $course): bool + { + return $user->email === 'allowed@example.com'; + } +}