From 19e65e36f3f963bce293d9e7566ea1ffe6f1e5b0 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Thu, 21 May 2026 21:21:03 +0200 Subject: [PATCH] feat: reset password --- .env.example | 1 - app/Http/Controllers/AuthController.php | 67 +++++++++++---- app/Mail/ResetPasswordMail.php | 58 +++++++++++++ app/Providers/AppServiceProvider.php | 17 ++-- config/services.php | 4 - resources/views/auth/reset-password.blade.php | 81 +++++++++++++++++++ .../views/emails/reset-password.blade.php | 63 +++++++++++++++ routes/web.php | 5 ++ tests/Feature/PasswordResetTest.php | 66 +++++++++++++-- 9 files changed, 330 insertions(+), 32 deletions(-) create mode 100644 app/Mail/ResetPasswordMail.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 resources/views/emails/reset-password.blade.php diff --git a/.env.example b/.env.example index 64fad73..fbb5c45 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,6 @@ 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 dda0ca5..1f2912e 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -13,6 +13,7 @@ use App\Models\User; use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Arr; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Illuminate\Support\ValidatedInput; use Illuminate\View\View; class AuthController extends Controller @@ -145,22 +147,7 @@ class AuthController extends Controller 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)); - } - ); + $status = $this->resetPasswordFor($request->safe()); if ($status !== Password::PASSWORD_RESET) { return response()->json([ @@ -173,6 +160,34 @@ class AuthController extends Controller ]); } + public function showResetPasswordForm(Request $request, string $token): View + { + return view('auth.reset-password', [ + 'appName' => config('app.name'), + 'email' => (string) $request->query('email', ''), + 'passwordReset' => false, + 'token' => $token, + ]); + } + + public function resetPasswordFromView(ResetPasswordRequest $request): RedirectResponse|View + { + $status = $this->resetPasswordFor($request->safe()); + + if ($status !== Password::PASSWORD_RESET) { + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } + + return view('auth.reset-password', [ + 'appName' => config('app.name'), + 'email' => strtolower($request->validated('email')), + 'passwordReset' => true, + 'token' => '', + ]); + } + public function logout(): JsonResponse { $request = request(); @@ -263,6 +278,26 @@ class AuthController extends Controller return filled($path) && ! Str::startsWith($path, ['http://', 'https://']); } + private function resetPasswordFor(ValidatedInput $data): string + { + $credentials = $data->all(); + $credentials['email'] = strtolower($credentials['email']); + + return Password::reset( + $credentials, + 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)); + } + ); + } + /** * @param array $data * @return array diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..e95279b --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,58 @@ + (int) config('auth.passwords.users.expire', 60), + 'resetUrl' => $this->resetUrl, + 'userName' => $this->user->name, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 49f61e5..86efd1d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Broadcasting\ExpoPushChannel; +use App\Mail\ResetPasswordMail; use App\Mail\VerifyAccount; use App\Models\User; use Illuminate\Auth\Notifications\ResetPassword; @@ -62,15 +63,19 @@ 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([ + $passwordResetUrl = function (User $notifiable, string $token): string { + return route('password.reset', [ 'email' => $notifiable->getEmailForPasswordReset(), 'token' => $token, - ], '', '&', PHP_QUERY_RFC3986); + ]); + }; - return "{$passwordResetUrl}?{$query}"; - }); + ResetPassword::createUrlUsing($passwordResetUrl); + + ResetPassword::toMailUsing( + fn (User $notifiable, string $token): ResetPasswordMail => (new ResetPasswordMail($notifiable, $passwordResetUrl($notifiable, $token))) + ->to($notifiable->getEmailForPasswordReset()) + ); Gate::define('viewApiDocs', function ($user = null) { // Option A : Autoriser tout le monde (Attention : la doc sera publique hors local) diff --git a/config/services.php b/config/services.php index 8bd4961..a74b68c 100644 --- a/config/services.php +++ b/config/services.php @@ -26,10 +26,6 @@ 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/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..8017d5f --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,81 @@ + + + + + + Nouveau mot de passe + + +
+

+ {{ $appName }} +

+ +

+ {{ $passwordReset ? 'Mot de passe modifié' : 'Nouveau mot de passe' }} +

+ + @if ($passwordReset) +

+ Ton mot de passe a bien été réinitialisé. Tu peux retourner dans l’application et te connecter. +

+ @else +

+ Choisis un nouveau mot de passe pour sécuriser ton compte. +

+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ @csrf + + + + + + + + + + +
+ @endif +
+ + diff --git a/resources/views/emails/reset-password.blade.php b/resources/views/emails/reset-password.blade.php new file mode 100644 index 0000000..e1b9a05 --- /dev/null +++ b/resources/views/emails/reset-password.blade.php @@ -0,0 +1,63 @@ + + + + + + Réinitialise ton mot de passe Bowli + + + + + + +
+ + + + +
+

+ Bowli +

+ +

+ Réinitialise ton mot de passe +

+ +

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

+ +

+ Clique sur le bouton ci-dessous pour créer un nouveau mot de passe. Le lien ouvre une page sécurisée de l’API Bowli. +

+ + + + + +
+ + Choisir un nouveau mot de passe + +
+ +

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

+ +

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

+ +

+ {{ $resetUrl }} +

+ +

+ Si tu n’as pas demandé de réinitialisation, tu peux ignorer cet email. +

+
+
+ + diff --git a/routes/web.php b/routes/web.php index a5dbc15..31fd33f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,12 @@ name('verification.notice'); +Route::get('password/reset/{token}', [AuthController::class, 'showResetPasswordForm'])->name('password.reset'); +Route::post('password/reset', [AuthController::class, 'resetPasswordFromView']) + ->middleware('throttle:6,1') + ->name('password.web-update'); Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback'); diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php index fd558df..c721de6 100644 --- a/tests/Feature/PasswordResetTest.php +++ b/tests/Feature/PasswordResetTest.php @@ -1,19 +1,23 @@ set('services.mobile.password_reset_url', 'bowly://reset-password'); + config()->set('app.url', 'https://api.bowli.test'); + URL::forceRootUrl('https://api.bowli.test'); + URL::forceScheme('https'); }); -it('sends a password reset email with a mobile app url', function () { +it('sends a password reset email with a clickable api web url', function () { Notification::fake(); $user = User::factory()->create([ @@ -32,14 +36,22 @@ it('sends a password reset email with a mobile app url', function () { ResetPassword::class, function (ResetPassword $notification) use ($user): bool { $mail = $notification->toMail($user); - $url = $mail->actionUrl; - expect($url)->toStartWith('bowly://reset-password?'); + expect($mail)->toBeInstanceOf(ResetPasswordMail::class); + + $url = $mail->resetUrl; + + expect($url)->toStartWith('https://api.bowli.test/password/reset/'); parse_str((string) parse_url($url, PHP_URL_QUERY), $query); expect($query['email'])->toBe($user->email) - ->and($query['token'])->not->toBeEmpty(); + ->and((string) parse_url($url, PHP_URL_PATH))->toStartWith('/password/reset/'); + + $mail + ->assertSeeInHtml('Réinitialise ton mot de passe') + ->assertSeeInHtml('Choisir un nouveau mot de passe') + ->assertSeeInHtml($user->name); return true; } @@ -85,6 +97,50 @@ it('resets the password and invalidates existing api tokens', function () { ]); }); +it('shows the api password reset form from the email link', function () { + $user = User::factory()->create([ + 'email' => 'leon@example.com', + ]); + $token = Password::broker()->createToken($user); + + $this + ->get(route('password.reset', [ + 'email' => $user->email, + 'token' => $token, + ], absolute: false)) + ->assertOk() + ->assertViewIs('auth.reset-password') + ->assertSee('Nouveau mot de passe') + ->assertSee($user->email); +}); + +it('resets the password from the api web form', function () { + $user = User::factory()->create([ + 'email' => 'leon@example.com', + 'password' => 'old-password', + ]); + $token = Password::broker()->createToken($user); + + $user->createToken('api'); + + $this + ->post('/password/reset', [ + 'email' => 'LEON@example.com', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + 'token' => $token, + ]) + ->assertOk() + ->assertViewIs('auth.reset-password') + ->assertSee('Mot de passe modifié'); + + 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',