diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e41df13..511ef83 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -50,6 +50,7 @@ jobs: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }} STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }} + run: | set -eu diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index f81b344..1a1b046 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -7,6 +7,7 @@ use App\Http\Requests\RegisterRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; +use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -14,10 +15,11 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Illuminate\View\View; class AuthController extends Controller { - public function register(RegisterRequest $request) + public function register(RegisterRequest $request): JsonResponse { $data = $request->validated(); @@ -33,9 +35,12 @@ class AuthController extends Controller 'avatar_url' => $avatarPath, ]); + $user->sendEmailVerificationNotification(); + $token = $user->createToken('api', ['user'])->plainTextToken; return response()->json([ + 'message' => 'Compte créé. Consulte tes emails pour le vérifier.', 'user' => new UserResource($user), ], 201)->cookie('token', $token, 60 * 24 * 30); } @@ -52,6 +57,12 @@ class AuthController extends Controller ], 401); } + if (! $user->hasVerifiedEmail()) { + return response()->json([ + 'message' => 'Vérifie ton adresse email avant de te connecter.', + ], 403); + } + $token = $user->createToken('api')->plainTextToken; return response()->json([ @@ -59,6 +70,55 @@ class AuthController extends Controller ])->cookie('token', $token, 60 * 24 * 30); } + public function verifyEmail(Request $request, string $id, string $hash): JsonResponse|View + { + $user = User::findOrFail($id); + + abort_unless( + hash_equals($hash, sha1($user->getEmailForVerification())), + 403 + ); + + $alreadyVerified = $user->hasVerifiedEmail(); + + if (! $alreadyVerified) { + $user->markEmailAsVerified(); + + event(new Verified($user)); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $alreadyVerified + ? 'Compte déjà vérifié.' + : 'Compte vérifié avec succès.', + 'user' => new UserResource($user->fresh()), + ]); + } + + return view('auth.email-verified', [ + 'alreadyVerified' => $alreadyVerified, + 'appName' => config('app.name'), + ]); + } + + public function sendVerificationEmail(Request $request): JsonResponse + { + $user = $request->user(); + + if ($user->hasVerifiedEmail()) { + return response()->json([ + 'message' => 'Compte déjà vérifié.', + ]); + } + + $user->sendEmailVerificationNotification(); + + return response()->json([ + 'message' => 'Email de vérification envoyé.', + ]); + } + public function logout(): JsonResponse { $request = request(); diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 7705cb3..80b1414 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -18,6 +18,8 @@ class UserResource extends JsonResource return [ 'name' => $this->name, 'email' => $this->email, + 'emailVerified' => $this->hasVerifiedEmail(), + 'emailVerifiedAt' => $this->email_verified_at?->toISOString(), 'height' => $this->height, 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, 'bio' => $this->bio, diff --git a/app/Mail/VerifyAccount.php b/app/Mail/VerifyAccount.php new file mode 100644 index 0000000..843d782 --- /dev/null +++ b/app/Mail/VerifyAccount.php @@ -0,0 +1,58 @@ + (int) config('auth.verification.expire', 60), + 'userName' => $this->user->name, + 'verificationUrl' => $this->verificationUrl, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 765d23f..162ef86 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,9 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -14,7 +14,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, HasUlids, Notifiable; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 90afb29..0d47c31 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,9 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; +use App\Mail\VerifyAccount; +use App\Models\User; +use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\URL; @@ -39,6 +42,11 @@ class AppServiceProvider extends ServiceProvider Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class)); + VerifyEmail::toMailUsing( + fn (User $notifiable, string $verificationUrl): VerifyAccount => (new VerifyAccount($notifiable, $verificationUrl)) + ->to($notifiable->getEmailForVerification()) + ); + Gate::define('viewApiDocs', function ($user = null) { // Option A : Autoriser tout le monde (Attention : la doc sera publique hors local) return true; diff --git a/composer.json b/composer.json index fa7d82b..62f3fdc 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "laravel/tinker": "^2.10.1", "meilisearch/meilisearch-php": "^1.16", "predis/predis": "^3.4", + "resend/resend-laravel": "^1.4", "scalar/laravel": "^0.2.0", "shuvroroy/filament-spatie-laravel-backup": "^3.3", "shuvroroy/filament-spatie-laravel-health": "^3.2", diff --git a/composer.lock b/composer.lock index 612d6d5..9cfb343 100644 --- a/composer.lock +++ b/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": "8883f80d7f60b090149b528c7dab2be3", + "content-hash": "27553433952beb112da135df2ad49a59", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6196,6 +6196,132 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "resend/resend-laravel", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/resend/resend-laravel.git", + "reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/6dd5f5ec607404068c5af067fd7f6ba4b659262b", + "reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b", + "shasum": "" + }, + "require": { + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "resend/resend-php": "^1.0.0", + "symfony/mailer": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.17|^9.0|^10.8|^11.0", + "pestphp/pest": "^1.0|^2.0|^3.7|^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Resend\\Laravel\\ResendServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Resend\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Resend and contributors", + "homepage": "https://github.com/resend/resend-laravel/contributors" + } + ], + "description": "Resend for Laravel", + "homepage": "https://resend.com/", + "keywords": [ + "api", + "client", + "laravel", + "php", + "resend", + "sdk" + ], + "support": { + "issues": "https://github.com/resend/resend-laravel/issues", + "source": "https://github.com/resend/resend-laravel/tree/v1.4.0" + }, + "time": "2026-05-06T17:08:44+00:00" + }, + { + "name": "resend/resend-php", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/resend/resend-php.git", + "reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/resend/resend-php/zipball/87d29d98271a0ab1c09cdbee102daa2f9b3419db", + "reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.5", + "php": "^8.1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.13", + "mockery/mockery": "^1.6", + "pestphp/pest": "^1.0|^2.0|^3.0|^4.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resend.php" + ], + "psr-4": { + "Resend\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Resend and contributors", + "homepage": "https://github.com/resend/resend-php/contributors" + } + ], + "description": "Resend PHP library.", + "homepage": "https://resend.com/", + "keywords": [ + "api", + "client", + "php", + "resend", + "sdk" + ], + "support": { + "issues": "https://github.com/resend/resend-php/issues", + "source": "https://github.com/resend/resend-php/tree/v1.3.0" + }, + "time": "2026-04-11T10:48:32+00:00" + }, { "name": "ryangjchandler/blade-capture-directive", "version": "v1.1.0", diff --git a/config/deploy.yml b/config/deploy.yml index 3cdaf36..16c197e 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -70,6 +70,9 @@ env: QUEUE_CONNECTION: redis SCOUT_DRIVER: meilisearch MEILISEARCH_HOST: http://bemeal-meilisearch:7700 + MAIL_MAILER: resend + MAIL_FROM_ADDRESS: onboarding@resend.dev + MAIL_FROM_NAME: Bowli secret: - APP_KEY - DB_DATABASE diff --git a/config/mail.php b/config/mail.php index 522b284..c04bb24 100644 --- a/config/mail.php +++ b/config/mail.php @@ -14,7 +14,7 @@ return [ | */ - 'default' => env('MAIL_MAILER', 'log'), + 'default' => env('MAIL_MAILER', 'resend'), /* |-------------------------------------------------------------------------- diff --git a/resources/views/auth/email-verified.blade.php b/resources/views/auth/email-verified.blade.php new file mode 100644 index 0000000..7238807 --- /dev/null +++ b/resources/views/auth/email-verified.blade.php @@ -0,0 +1,23 @@ + + + + + + Compte vérifié + + +
+

+ {{ $appName }} +

+ +

+ {{ $alreadyVerified ? 'Compte déjà vérifié' : 'Compte vérifié' }} +

+ +

+ Tu peux maintenant retourner dans l’application. +

+
+ + diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..f201d16 --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,19 @@ + + + + + + Vérification requise + + +
+

+ Vérification requise +

+ +

+ Consulte tes emails et clique sur le lien de vérification pour accéder à ton compte. +

+
+ + diff --git a/resources/views/emails/verify-account.blade.php b/resources/views/emails/verify-account.blade.php new file mode 100644 index 0000000..314cdfa --- /dev/null +++ b/resources/views/emails/verify-account.blade.php @@ -0,0 +1,55 @@ + + + + + + Vérifie ton compte Daily Meal + + + + + + +
+ + + + +
+

+ Daily Meal +

+ +

+ Vérifie ton compte +

+ +

+ Bonjour {{ $userName ?: 'à toi' }}, +

+ +

+ Confirme ton adresse email pour activer ton compte et accéder à Daily Meal. +

+ + + + + +
+ + Vérifier mon compte + +
+ +

+ Ce lien expire dans {{ $expiresInMinutes }} minutes. +

+ +

+ Si tu n’as pas créé de compte Daily Meal, tu peux ignorer cet email. +

+
+
+ + diff --git a/routes/api.php b/routes/api.php index 77cc7eb..6eb4d53 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,10 +13,17 @@ 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::middleware('auth:sanctum')->group(function (): void { Route::post('logout', [AuthController::class, 'logout']); Route::get('me', [AuthController::class, 'me']); - Route::patch('me', [AuthController::class, 'update']); + Route::post('email/verification-notification', [AuthController::class, 'sendVerificationEmail']) + ->middleware('throttle:6,1') + ->name('verification.send'); + Route::patch('me', [AuthController::class, 'update'])->middleware('verified'); Route::delete('me', [AuthController::class, 'destroy']); }); @@ -26,14 +33,14 @@ Route::prefix('auth')->group(function (): void { Route::get('/health', function () { return response()->json(['status' => 'ok']); }); -Route::middleware('auth:sanctum')->group(function (): void { +Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store']); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']); Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); }); // Workouts -Route::middleware('auth:sanctum')->group(function (): void { +Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store'); Route::get('strava/status', [StravaController::class, 'status'])->name('strava.status'); @@ -43,7 +50,7 @@ Route::middleware('auth:sanctum')->group(function (): void { }); // Meals -Route::middleware('auth:sanctum')->group(function (): void { +Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); diff --git a/routes/web.php b/routes/web.php index c6e4daf..a5dbc15 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,4 +3,5 @@ use App\Http\Controllers\StravaController; use Illuminate\Support\Facades\Route; +Route::view('email/verify', 'auth.verify-email')->name('verification.notice'); Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback'); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php new file mode 100644 index 0000000..9cdabc4 --- /dev/null +++ b/tests/Feature/EmailVerificationTest.php @@ -0,0 +1,122 @@ +postJson('/api/auth/register', [ + 'name' => 'Léon', + 'email' => 'leon@example.com', + 'password' => 'password', + ]) + ->assertCreated() + ->assertJsonPath('user.email', 'leon@example.com') + ->assertJsonPath('user.emailVerified', false); + + $user = User::where('email', 'leon@example.com')->firstOrFail(); + + expect($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('uses the custom account verification mailable', function () { + $user = User::factory()->unverified()->create(); + + $mailable = (new VerifyEmail)->toMail($user); + + expect($mailable)->toBeInstanceOf(VerifyAccount::class); + + $mailable + ->assertSeeInHtml('Vérifier mon compte') + ->assertSeeInHtml($user->name); +}); + +it('verifies a user from a signed email link', function () { + $user = User::factory()->unverified()->create(); + $url = URL::temporarySignedRoute('verification.verify', now()->addMinutes(60), [ + 'id' => $user->getKey(), + 'hash' => sha1($user->email), + ]); + + $this->getJson($url) + ->assertOk() + ->assertJsonPath('message', 'Compte vérifié avec succès.') + ->assertJsonPath('user.emailVerified', true); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +it('rejects signed verification links with an invalid hash', function () { + $user = User::factory()->unverified()->create(); + $url = URL::temporarySignedRoute('verification.verify', now()->addMinutes(60), [ + 'id' => $user->getKey(), + 'hash' => sha1('wrong-address@example.com'), + ]); + + $this->getJson($url)->assertForbidden(); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('blocks login for unverified users', function () { + $user = User::factory()->unverified()->create([ + 'password' => 'password', + ]); + + $this->postJson('/api/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]) + ->assertForbidden() + ->assertJsonPath('message', 'Vérifie ton adresse email avant de te connecter.') + ->assertCookieMissing('token'); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->getKey(), + ]); +}); + +it('allows login for verified users', function () { + $user = User::factory()->create([ + 'password' => 'password', + ]); + + $this->postJson('/api/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]) + ->assertOk() + ->assertJsonPath('user.email', $user->email) + ->assertJsonPath('user.emailVerified', true) + ->assertCookie('token'); +}); + +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', 'Email de vérification envoyé.'); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('blocks verified routes for unverified users', function () { + $user = User::factory()->unverified()->create(); + Sanctum::actingAs($user); + + $this->getJson('/api/meal-posts')->assertForbidden(); +});