diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7786b82..3ae7203 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -59,6 +59,15 @@ deploy-to-portainer: - apk add --no-cache curl jq gettext script: + - | + export MAIL_MAILER="${MAIL_MAILER:-smtp}" + export MAIL_SCHEME="${MAIL_SCHEME:-null}" + export MAIL_FROM_NAME="${MAIL_FROM_NAME:-$APP_NAME}" + : "${MAIL_HOST:?Missing MAIL_HOST GitLab CI/CD variable}" + : "${MAIL_PORT:?Missing MAIL_PORT GitLab CI/CD variable}" + : "${MAIL_USERNAME:?Missing MAIL_USERNAME GitLab CI/CD variable}" + : "${MAIL_PASSWORD:?Missing MAIL_PASSWORD GitLab CI/CD variable}" + : "${MAIL_FROM_ADDRESS:?Missing MAIL_FROM_ADDRESS GitLab CI/CD variable}" - envsubst < docker-compose.prod.yml > docker-compose.rendered.yml - | diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index e646205..740edce 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -6,9 +6,11 @@ use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\RegisterRequest; use App\Models\User; use Hash; +use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use Illuminate\View\View; use Symfony\Component\HttpFoundation\Response; class AuthController extends Controller @@ -36,6 +38,8 @@ class AuthController extends Controller $this->resolveDeviceName($data) )->plainTextToken; + $user->sendEmailVerificationNotification(); + return response()->json([ 'token' => $token, 'token_type' => 'Bearer', @@ -79,6 +83,47 @@ class AuthController extends Controller return response()->json($user); } + public function verifyEmail(Request $request, string $id, string $hash): JsonResponse|View + { + $user = User::findOrFail($id); + + if (! hash_equals($hash, sha1($user->getEmailForVerification()))) { + abort(Response::HTTP_FORBIDDEN); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + + event(new Verified($user)); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('auth.email_verified'), + ]); + } + + return view('auth.email-verified'); + } + + public function resendEmailVerificationNotification(Request $request): JsonResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + if ($user->hasVerifiedEmail()) { + return response()->json([ + 'message' => __('auth.email_already_verified'), + ]); + } + + $user->sendEmailVerificationNotification(); + + return response()->json([ + 'message' => __('auth.email_verification_sent'), + ]); + } + public function logout(Request $request): JsonResponse { $request->user()?->currentAccessToken()?->delete(); diff --git a/app/Models/User.php b/app/Models/User.php index 9d4b71d..6808feb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,11 +2,12 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\UserRole; +use App\Notifications\VerifyEmailNotification; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; use Filament\Panel; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,7 +16,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements FilamentUser, HasName +class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, HasUuids, Notifiable, SoftDeletes; @@ -96,6 +97,11 @@ class User extends Authenticatable implements FilamentUser, HasName return $this->belongsTo(Level::class, 'level_id'); } + public function sendEmailVerificationNotification(): void + { + $this->notify(new VerifyEmailNotification); + } + public function updateLevel(): void { $level = Level::where('xpThreshold', '<=', $this->xp) diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php new file mode 100644 index 0000000..88bfcfb --- /dev/null +++ b/app/Notifications/VerifyEmailNotification.php @@ -0,0 +1,48 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + /** @var MustVerifyEmail $notifiable */ + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(Config::get('auth.verification.expire', 60)), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ], + ); + + return (new MailMessage) + ->subject('Vérifie ton adresse email') + ->view('mail.auth.verify-email', [ + 'notifiable' => $notifiable, + 'verificationUrl' => $verificationUrl, + ]); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4e7ad10..1c2f413 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,6 +20,14 @@ services: DB_DATABASE: "${DB_DATABASE}" DB_USERNAME: "${DB_USERNAME}" DB_PASSWORD: "${DB_PASSWORD}" + MAIL_MAILER: "${MAIL_MAILER}" + MAIL_SCHEME: "${MAIL_SCHEME}" + MAIL_HOST: "${MAIL_HOST}" + MAIL_PORT: "${MAIL_PORT}" + MAIL_USERNAME: "${MAIL_USERNAME}" + MAIL_PASSWORD: "${MAIL_PASSWORD}" + MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}" + MAIL_FROM_NAME: "${MAIL_FROM_NAME}" volumes: - storage-data:/var/www/html/storage/app diff --git a/lang/en/auth.php b/lang/en/auth.php index b8a0b6f..5a16e20 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -17,5 +17,8 @@ return [ 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logout_success' => 'You have been deconnecte', + 'email_verified' => 'Email address verified.', + 'email_already_verified' => 'Email address already verified.', + 'email_verification_sent' => 'Verification link sent.', ]; diff --git a/lang/fr/auth.php b/lang/fr/auth.php index d5ad527..9f84dfd 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -17,5 +17,8 @@ return [ 'password' => 'Email ou mot de passe incorrect', 'throttle' => 'Trop de tentatives veuillez reessayer dans :seconds seconds.', 'logout_success' => 'Vous êtes bien déconnecté', + 'email_verified' => 'Adresse email vérifiée.', + 'email_already_verified' => 'Adresse email déjà vérifiée.', + 'email_verification_sent' => 'Lien de vérification envoyé.', ]; diff --git a/resources/views/auth/email-verified.blade.php b/resources/views/auth/email-verified.blade.php new file mode 100644 index 0000000..fd262b2 --- /dev/null +++ b/resources/views/auth/email-verified.blade.php @@ -0,0 +1,48 @@ + + + + + + Email vérifié + + + +
+

Email vérifié

+

Ton adresse email a bien été validée. Tu peux maintenant retourner sur l'application.

+
+ + diff --git a/resources/views/mail/auth/verify-email.blade.php b/resources/views/mail/auth/verify-email.blade.php new file mode 100644 index 0000000..612e968 --- /dev/null +++ b/resources/views/mail/auth/verify-email.blade.php @@ -0,0 +1,52 @@ + + + + + + Vérifie ton adresse email + + + + + + +
+ + + + + + + + + + +
+

{{ config('app.name') }}

+

Vérifie ton adresse email

+
+

+ Bonjour {{ $notifiable->username }}, +

+

+ Clique sur le bouton ci-dessous pour confirmer ton adresse email et activer ton compte. +

+

+ + Vérifier mon email + +

+

+ Si le bouton ne fonctionne pas, ouvre ce lien dans ton navigateur : +

+

+ {{ $verificationUrl }} +

+
+

+ Si tu n'es pas à l'origine de cette demande, tu peux ignorer cet email. +

+
+
+ + diff --git a/routes/api.php b/routes/api.php index da21caa..acfe53a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,9 @@ use Illuminate\Support\Facades\Route; Route::prefix('auth')->group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); + Route::get('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); // Route::get('{provider}/redirect', [ProviderRedirectController::class, 'redirect']); // Route::get('{provider}/callback', [ProviderCallbackController::class, 'callback']); }); @@ -18,12 +21,12 @@ Route::prefix('auth')->group(function (): void { // List hunts // Ensure is Admin -Route::middleware(['auth:sanctum', EnsureUserIsAdmin::class])->group(function () { +Route::middleware(['auth:sanctum', 'verified', EnsureUserIsAdmin::class])->group(function () { Route::post('users/{user}/delete', [UsersController::class, 'softDeleteById'])->withTrashed(); }); // Ensure is Orga -Route::middleware(['auth:sanctum', EnsureUserIsOrga::class])->group(function () { +Route::middleware(['auth:sanctum', 'verified', EnsureUserIsOrga::class])->group(function () { Route::post('hunts', [HuntsController::class, 'store']); Route::post('hunts/{hunt}/steps', [HuntStepsController::class, 'store']); Route::match(['put', 'patch'], 'hunts/{hunt}', [HuntsController::class, 'update']); @@ -32,6 +35,14 @@ Route::middleware(['auth:sanctum', EnsureUserIsOrga::class])->group(function () // Auth middleware Route::middleware('auth:sanctum')->group(function (): void { + Route::post('auth/email/verification-notification', [AuthController::class, 'resendEmailVerificationNotification']) + ->middleware('throttle:6,1') + ->name('verification.send'); + Route::get('auth/me', [AuthController::class, 'me']); + Route::post('auth/logout', [AuthController::class, 'logout']); +}); + +Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { Route::get('hunts', [HuntsController::class, 'index']); Route::get('hunts/{hunt}', [HuntsController::class, 'show']); // Auth @@ -45,6 +56,4 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::post('hunts/{hunt}/participate', [HuntsController::class, 'participate']); Route::delete('hunts/{hunt}/participate', [HuntsController::class, 'unparticipate']); Route::post('hunts/{hunt}/steps/{step}/validate', [HuntStepsController::class, 'validateStep']); - Route::get('auth/me', [AuthController::class, 'me']); - Route::post('auth/logout', [AuthController::class, 'logout']); }); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..5faecd4 --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,94 @@ +postJson('/api/auth/register', [ + 'username' => 'new-user', + 'email' => 'new-user@example.com', + 'password' => 'Password1!', + 'password_confirmation' => 'Password1!', + 'device_name' => 'iphone', + ])->assertCreated(); + + $user = User::where('email', 'new-user@example.com')->firstOrFail(); + + expect($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo($user, VerifyEmailNotification::class, function (VerifyEmailNotification $notification) use ($user) { + $mail = $notification->toMail($user); + + expect($mail->subject)->toBe('Vérifie ton adresse email') + ->and($mail->render())->toContain('Vérifier mon email'); + + return true; + }); +}); + +it('resends a verification email for an authenticated unverified user', function () { + Notification::fake(); + + $user = User::factory()->unverified()->create(); + + Sanctum::actingAs($user); + + $this->postJson('/api/auth/email/verification-notification') + ->assertOk() + ->assertJsonPath('message', __('auth.email_verification_sent')); + + Notification::assertSentTo($user, VerifyEmailNotification::class); +}); + +it('verifies the user email from a signed verification link', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ], + ); + + $this->getJson($verificationUrl) + ->assertOk() + ->assertJsonPath('message', __('auth.email_verified')); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +it('shows a confirmation page when the verification link is opened in a browser', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ], + ); + + $this->get($verificationUrl) + ->assertOk() + ->assertSeeText('Email vérifié') + ->assertSeeText("Ton adresse email a bien été validée. Tu peux maintenant retourner sur l'application.", false); +}); + +it('blocks verified routes for authenticated unverified users', function () { + $user = User::factory()->unverified()->create(); + + Sanctum::actingAs($user); + + $this->getJson('/api/hunts')->assertForbidden(); +});