From 02c4b4fd5f720a559d2378aec60aebe2c57130a6 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 13 Apr 2026 07:39:11 +0200 Subject: [PATCH] feat(api): password reset endpoints with portal URL Add forgot-password and reset-password API routes with rate limiting. Customize reset URL to point to portal frontend via AppServiceProvider. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Api/V1/PasswordResetController.php | 48 ++++++ api/app/Providers/AppServiceProvider.php | 5 +- api/routes/api.php | 6 + .../Feature/Api/V1/PasswordResetTest.php | 154 ++++++++++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 api/app/Http/Controllers/Api/V1/PasswordResetController.php create mode 100644 api/tests/Feature/Api/V1/PasswordResetTest.php diff --git a/api/app/Http/Controllers/Api/V1/PasswordResetController.php b/api/app/Http/Controllers/Api/V1/PasswordResetController.php new file mode 100644 index 00000000..9af0d238 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PasswordResetController.php @@ -0,0 +1,48 @@ +validate(['email' => 'required|email']); + + Password::sendResetLink(['email' => strtolower($request->email)]); + + // Always return success (don't leak whether email exists) + return $this->success( + message: 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.' + ); + } + + public function resetPassword(Request $request): JsonResponse + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|min:8|confirmed', + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user, $password) { + $user->forceFill(['password' => Hash::make($password)])->save(); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return $this->success(message: 'Wachtwoord succesvol gewijzigd.'); + } + + return $this->error(__($status), 422); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 89e71fd9..8cdf1bef 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Providers; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -15,6 +16,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - // + ResetPassword::createUrlUsing(function ($user, string $token) { + return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); + }); } } diff --git a/api/routes/api.php b/api/routes/api.php index 38222b8f..1d7b77d2 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -29,6 +29,7 @@ use App\Http\Controllers\Api\V1\VolunteerAvailabilityController; use App\Http\Controllers\Api\V1\VolunteerRegistrationController; use App\Http\Controllers\Api\V1\PublicRegistrationDataController; use App\Http\Controllers\Api\V1\PortalTokenController; +use App\Http\Controllers\Api\V1\PasswordResetController; use App\Http\Controllers\Api\V1\PortalMeController; use App\Http\Controllers\Api\V1\UserOrganisationTagController; use App\Models\FestivalSection; @@ -59,6 +60,11 @@ Route::post('auth/login', LoginController::class); Route::get('invitations/{token}', [InvitationController::class, 'show']); Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']); +// Password reset +Route::post('auth/forgot-password', [PasswordResetController::class, 'sendResetLink']) + ->middleware('throttle:5,1'); +Route::post('auth/reset-password', [PasswordResetController::class, 'resetPassword']); + // Public portal routes Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class); Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); diff --git a/api/tests/Feature/Api/V1/PasswordResetTest.php b/api/tests/Feature/Api/V1/PasswordResetTest.php new file mode 100644 index 00000000..b20c1f72 --- /dev/null +++ b/api/tests/Feature/Api/V1/PasswordResetTest.php @@ -0,0 +1,154 @@ +create(['email' => 'jan@voorbeeld.nl']); + + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'jan@voorbeeld.nl', + ]); + + $response->assertOk(); + $response->assertJsonPath('message', 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.'); + } + + public function test_forgot_password_returns_same_success_for_nonexisting_email(): void + { + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'onbekend@voorbeeld.nl', + ]); + + $response->assertOk(); + $response->assertJsonPath('message', 'Als dit emailadres bij ons bekend is, ontvang je een link om je wachtwoord te resetten.'); + } + + public function test_forgot_password_validates_email_required(): void + { + $response = $this->postJson('/api/v1/auth/forgot-password', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + public function test_forgot_password_validates_email_format(): void + { + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'not-an-email', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + public function test_forgot_password_is_rate_limited(): void + { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'test@voorbeeld.nl', + ]); + } + + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'test@voorbeeld.nl', + ]); + + $response->assertStatus(429); + } + + // ─── Reset Password ───────────────────────────────────────────────── + + public function test_reset_password_with_valid_token(): void + { + $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']); + + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => $token, + 'email' => 'jan@voorbeeld.nl', + 'password' => 'nieuwwachtwoord123', + 'password_confirmation' => 'nieuwwachtwoord123', + ]); + + $response->assertOk(); + $response->assertJsonPath('message', 'Wachtwoord succesvol gewijzigd.'); + + // Verify password was actually changed + $user->refresh(); + $this->assertTrue(Hash::check('nieuwwachtwoord123', $user->password)); + } + + public function test_reset_password_with_invalid_token_returns_422(): void + { + User::factory()->create(['email' => 'jan@voorbeeld.nl']); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => 'invalid-token-here', + 'email' => 'jan@voorbeeld.nl', + 'password' => 'nieuwwachtwoord123', + 'password_confirmation' => 'nieuwwachtwoord123', + ]); + + $response->assertStatus(422); + } + + public function test_reset_password_requires_confirmation(): void + { + $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']); + + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => $token, + 'email' => 'jan@voorbeeld.nl', + 'password' => 'nieuwwachtwoord123', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('password'); + } + + public function test_reset_password_requires_minimum_length(): void + { + $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']); + + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => $token, + 'email' => 'jan@voorbeeld.nl', + 'password' => 'short', + 'password_confirmation' => 'short', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('password'); + } + + public function test_reset_password_validates_required_fields(): void + { + $response = $this->postJson('/api/v1/auth/reset-password', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['token', 'email', 'password']); + } +}