diff --git a/.env.example b/.env.example index fbb5c45..64fad73 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,7 @@ MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" EXPO_ACCESS_TOKEN= +MOBILE_PASSWORD_RESET_URL=bowly://reset-password STRAVA_CLIENT_ID= STRAVA_CLIENT_SECRET= diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 6991fde..dda0ca5 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -3,11 +3,14 @@ namespace App\Http\Controllers; use App\Http\Requests\EmailVerificationNotificationRequest; +use App\Http\Requests\ForgotPasswordRequest; use App\Http\Requests\LoginRequest; use App\Http\Requests\RegisterRequest; +use App\Http\Requests\ResetPasswordRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; +use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -15,6 +18,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\View\View; @@ -123,6 +127,52 @@ class AuthController extends Controller ]); } + public function forgotPassword(ForgotPasswordRequest $request): JsonResponse + { + $email = strtolower($request->validated('email')); + $status = Password::sendResetLink(['email' => $email]); + + if ($status === Password::RESET_THROTTLED) { + return response()->json([ + 'message' => __($status), + ], 429); + } + + return response()->json([ + 'message' => __('api.auth.password_reset_link_sent'), + ]); + } + + public function resetPassword(ResetPasswordRequest $request): JsonResponse + { + $data = $request->validated(); + $data['email'] = strtolower($data['email']); + + $status = Password::reset( + $data, + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + $user->tokens()->delete(); + + event(new PasswordReset($user)); + } + ); + + if ($status !== Password::PASSWORD_RESET) { + return response()->json([ + 'message' => __($status), + ], 422); + } + + return response()->json([ + 'message' => __('api.auth.password_reset'), + ]); + } + public function logout(): JsonResponse { $request = request(); diff --git a/app/Http/Requests/ForgotPasswordRequest.php b/app/Http/Requests/ForgotPasswordRequest.php new file mode 100644 index 0000000..cdbb084 --- /dev/null +++ b/app/Http/Requests/ForgotPasswordRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php new file mode 100644 index 0000000..b4bb309 --- /dev/null +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'token' => ['required', 'string'], + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f2e9a0b..49f61e5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; use App\Mail\VerifyAccount; use App\Models\User; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Notification; @@ -61,6 +62,16 @@ class AppServiceProvider extends ServiceProvider ->to($notifiable->getEmailForVerification()) ); + ResetPassword::createUrlUsing(function (User $notifiable, string $token): string { + $passwordResetUrl = rtrim((string) config('services.mobile.password_reset_url'), '?&'); + $query = http_build_query([ + 'email' => $notifiable->getEmailForPasswordReset(), + 'token' => $token, + ], '', '&', PHP_QUERY_RFC3986); + + return "{$passwordResetUrl}?{$query}"; + }); + Gate::define('viewApiDocs', function ($user = null) { // Option A : Autoriser tout le monde (Attention : la doc sera publique hors local) return true; diff --git a/config/services.php b/config/services.php index a74b68c..8bd4961 100644 --- a/config/services.php +++ b/config/services.php @@ -26,6 +26,10 @@ return [ 'access_token' => env('EXPO_ACCESS_TOKEN'), ], + 'mobile' => [ + 'password_reset_url' => env('MOBILE_PASSWORD_RESET_URL', 'bowly://reset-password'), + ], + 'strava' => [ 'client_id' => env('STRAVA_CLIENT_ID'), 'client_secret' => env('STRAVA_CLIENT_SECRET'), diff --git a/lang/en/api.php b/lang/en/api.php index 0daccd4..cb1d6bd 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -9,6 +9,8 @@ return [ 'verified' => 'Account verified successfully.', 'verification_sent' => 'Verification email sent.', 'verification_sent_if_unverified' => 'If an unverified account exists for this address, a verification email has been sent.', + 'password_reset_link_sent' => 'If an account exists for this address, a password reset link has been sent.', + 'password_reset' => 'Password reset successfully.', 'logout' => 'Logged out successfully.', 'unauthenticated' => 'Unauthenticated.', 'deleted' => 'Account deleted successfully.', diff --git a/lang/fr/api.php b/lang/fr/api.php index 8a5c710..c467ac4 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -9,6 +9,8 @@ return [ 'verified' => 'Compte vérifié avec succès.', 'verification_sent' => 'Email de vérification envoyé.', 'verification_sent_if_unverified' => 'Si un compte non vérifié existe avec cette adresse, un email de vérification vient d’être envoyé.', + 'password_reset_link_sent' => 'Si un compte existe avec cette adresse, un lien de réinitialisation vient d’être envoyé.', + 'password_reset' => 'Mot de passe réinitialisé avec succès.', 'logout' => 'Déconnecté avec succès.', 'unauthenticated' => 'Non authentifié.', 'deleted' => 'Compte supprimé avec succès.', diff --git a/routes/api.php b/routes/api.php index d24c710..e8b03e9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,12 @@ use Illuminate\Support\Facades\Route; Route::prefix('auth')->group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); + Route::post('forgot-password', [AuthController::class, 'forgotPassword']) + ->middleware('throttle:6,1') + ->name('password.email'); + Route::post('reset-password', [AuthController::class, 'resetPassword']) + ->middleware('throttle:6,1') + ->name('password.update'); Route::get('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail']) ->middleware(['signed:relative', 'throttle:6,1']) ->name('verification.verify'); diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php new file mode 100644 index 0000000..fd558df --- /dev/null +++ b/tests/Feature/PasswordResetTest.php @@ -0,0 +1,104 @@ +set('services.mobile.password_reset_url', 'bowly://reset-password'); +}); + +it('sends a password reset email with a mobile app url', function () { + Notification::fake(); + + $user = User::factory()->create([ + 'email' => 'leon@example.com', + ]); + + $this + ->postJson('/api/auth/forgot-password', [ + 'email' => 'LEON@example.com', + ]) + ->assertOk() + ->assertJsonPath('message', __('api.auth.password_reset_link_sent')); + + Notification::assertSentTo( + $user, + ResetPassword::class, + function (ResetPassword $notification) use ($user): bool { + $mail = $notification->toMail($user); + $url = $mail->actionUrl; + + expect($url)->toStartWith('bowly://reset-password?'); + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($query['email'])->toBe($user->email) + ->and($query['token'])->not->toBeEmpty(); + + return true; + } + ); +}); + +it('does not reveal whether a reset email can be sent', function () { + Notification::fake(); + + $this + ->postJson('/api/auth/forgot-password', [ + 'email' => 'missing@example.com', + ]) + ->assertOk() + ->assertJsonPath('message', __('api.auth.password_reset_link_sent')); + + Notification::assertNothingSent(); +}); + +it('resets the password and invalidates existing api tokens', function () { + $user = User::factory()->create([ + 'email' => 'leon@example.com', + 'password' => 'old-password', + ]); + $token = Password::broker()->createToken($user); + + $user->createToken('api'); + + $this + ->postJson('/api/auth/reset-password', [ + 'email' => 'LEON@example.com', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + 'token' => $token, + ]) + ->assertOk() + ->assertJsonPath('message', __('api.auth.password_reset')); + + expect(Hash::check('new-password', $user->fresh()->password))->toBeTrue(); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->getKey(), + ]); +}); + +it('rejects an invalid password reset token', function () { + $user = User::factory()->create([ + 'email' => 'leon@example.com', + ]); + + $this + ->postJson('/api/auth/reset-password', [ + 'email' => $user->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + 'token' => 'invalid-token', + ]) + ->assertUnprocessable() + ->assertJsonPath('message', __('passwords.token')); + + expect(Hash::check('new-password', $user->fresh()->password))->toBeFalse(); +});