diff --git a/src/Http/Controllers/Auth/VerifyEmailController.php b/src/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000..cde1a7d5 --- /dev/null +++ b/src/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,21 @@ +fulfill(); + + return redirect()->intended(route('cachet.status-page', absolute: false).'?verified=1'); + } +} diff --git a/src/Models/User.php b/src/Models/User.php index 7b3d85f4..3f09852d 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -4,6 +4,7 @@ use Cachet\Concerns\CachetUser; use Cachet\Database\Factories\UserFactory; +use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Factories\Factory; @@ -23,7 +24,7 @@ class User extends Authenticatable implements CachetUser, HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Cachet\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, MustVerifyEmailTrait; /** * The attributes that are mass assignable. diff --git a/src/PendingRouteRegistration.php b/src/PendingRouteRegistration.php index ae3614a8..3ff847e9 100644 --- a/src/PendingRouteRegistration.php +++ b/src/PendingRouteRegistration.php @@ -2,6 +2,8 @@ namespace Cachet; +use Cachet\Http\Controllers\Auth\EmailVerificationPromptController; +use Cachet\Http\Controllers\Auth\VerifyEmailController; use Cachet\Http\Controllers\HealthController; use Cachet\Http\Controllers\RssController; use Cachet\Http\Controllers\Setup\SetupController; @@ -40,9 +42,22 @@ public function register(): void $router->get('/health', HealthController::class)->name('health'); $router->get('/rss', RssController::class)->name('rss'); + }); + + $this->registerEmailVerificationRoutes(); + } + + private function registerEmailVerificationRoutes(): void + { + Route::middleware('auth')->group(function () { + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + }); } + /** * Handle the object's destruction. * diff --git a/tests/Feature/Http/Controllers/Auth/EmailVerificationTest.php b/tests/Feature/Http/Controllers/Auth/EmailVerificationTest.php new file mode 100644 index 00000000..0dbc86ec --- /dev/null +++ b/tests/Feature/Http/Controllers/Auth/EmailVerificationTest.php @@ -0,0 +1,45 @@ +unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('cachet.status-page', absolute: false).'?verified=1'); +}); + +test('email is not verified with invalid hash', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + actingAs($user)->get($verificationUrl); + assertFalse($user->fresh()->hasVerifiedEmail()); +}); diff --git a/workbench/database/factories/UserFactory.php b/workbench/database/factories/UserFactory.php index 6bdbc209..a9a9050a 100644 --- a/workbench/database/factories/UserFactory.php +++ b/workbench/database/factories/UserFactory.php @@ -44,4 +44,14 @@ public function active() ]; }); } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } }