diff --git a/README.md b/README.md index 478e4ac..77a646b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ - [POST] **/sessions - Login** DONE - [DELETE] **/sessions - Logout** DONE +### Password Reset +- [POST] **/password/email - Request Password Reset** DONE +- [PUT] **/password/reset - Reset Password with Token** DONE + ### Profile Management - [GET] **/users/{user_id} - Get User Profile by Username** DONE diff --git a/app/Actions/ResetPassword.php b/app/Actions/ResetPassword.php new file mode 100644 index 0000000..2a155b9 --- /dev/null +++ b/app/Actions/ResetPassword.php @@ -0,0 +1,34 @@ + $token, + 'email' => $email, + 'password' => $password, + 'password_confirmation' => $password_confirmation, + ], + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + ])->setRememberToken(Str::random(60)); + + $user->save(); + } + ); + + return $status === Password::PASSWORD_RESET ? Password::PASSWORD_RESET : Password::INVALID_TOKEN; + } +} diff --git a/app/Actions/SendPasswordResetCode.php b/app/Actions/SendPasswordResetCode.php new file mode 100644 index 0000000..71b434d --- /dev/null +++ b/app/Actions/SendPasswordResetCode.php @@ -0,0 +1,23 @@ +where('email', $email)->first(); + if (! $user) { + return; + } + + $token = Password::createToken($user); + $user->notify(new PasswordResetNotification($token)); + } +} diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php new file mode 100644 index 0000000..4e68fd3 --- /dev/null +++ b/app/Http/Controllers/PasswordResetController.php @@ -0,0 +1,47 @@ +string('email')->toString(); + + $action->handle($email); + + return response(status: 200); + } + + public function update( + ResetPasswordRequest $request, + ResetPassword $action + ): JsonResponse { + $token = $request->string('token')->toString(); + $email = $request->string('email')->toString(); + $password = $request->string('password')->toString(); + $password_confirmation = $request->string('password_confirmation')->toString(); + + $status = $action->handle($token, $email, $password, $password_confirmation); + + if ($status === Password::PASSWORD_RESET) { + return response()->json([ + 'message' => 'Password reset successfully.', + ]); + } + + return response()->json(['message' => __($status)], 400); + } +} diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php new file mode 100644 index 0000000..afb2e1e --- /dev/null +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -0,0 +1,22 @@ +> + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string'], + 'email' => ['required', 'email', 'exists:users'], + 'password' => ['required', 'confirmed', 'min:8'], + ]; + } +} diff --git a/app/Http/Requests/SendPasswordResetCodeRequest.php b/app/Http/Requests/SendPasswordResetCodeRequest.php new file mode 100644 index 0000000..6327633 --- /dev/null +++ b/app/Http/Requests/SendPasswordResetCodeRequest.php @@ -0,0 +1,20 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['bail', 'required', 'email'], + ]; + } +} diff --git a/app/Notifications/PasswordResetNotification.php b/app/Notifications/PasswordResetNotification.php new file mode 100644 index 0000000..f812942 --- /dev/null +++ b/app/Notifications/PasswordResetNotification.php @@ -0,0 +1,51 @@ + + */ + public function via(User $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Your Password Reset Code') + ->greeting("Hello, {$notifiable->username}!") + ->line('Here is your code:') + ->line("**{$this->token}**") + ->line('Use this code to reset your password in Supo CLI.') + ->line('If you did not request a password reset, no further action is required.'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'token' => $this->token, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 8b25efa..32194e0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\FollowingFeedController; use App\Http\Controllers\ForYouFeedController; use App\Http\Controllers\LikeController; +use App\Http\Controllers\PasswordResetController; use App\Http\Controllers\PostController; use App\Http\Controllers\SessionController; use App\Http\Controllers\UserController; @@ -15,6 +16,10 @@ // Sessions... Route::post('/sessions', [SessionController::class, 'store'])->name('sessions.store'); +// Password Reset... +Route::post('/password/email', [PasswordResetController::class, 'store'])->name('password.email'); +Route::put('/password/reset', [PasswordResetController::class, 'update'])->name('password.reset'); + // Users... Route::post('/users', [UserController::class, 'store'])->name('users.store'); diff --git a/tests/Feature/Http/PasswordResetControllerTest.php b/tests/Feature/Http/PasswordResetControllerTest.php new file mode 100644 index 0000000..6845a64 --- /dev/null +++ b/tests/Feature/Http/PasswordResetControllerTest.php @@ -0,0 +1,77 @@ +create(); + + $response = $this->postJson(route('password.email'), ['email' => $user->email]); + + $response->assertStatus(200); + Notification::assertSentTo($user, PasswordResetNotification::class); +}); + +it('can reset password using a valid token', function (): void { + $user = User::factory()->create(); + + $this->postJson(route('password.email'), ['email' => $user->email]); + + Notification::assertSentTo($user, PasswordResetNotification::class, function (PasswordResetNotification $notification) use ($user) { + $notificationData = $notification->toArray($user); + + $this->putJson(route('password.reset'), [ + 'token' => $notificationData['token'] ?? '', + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]) + ->assertSessionHasNoErrors() + ->assertStatus(200); + + return true; + }); +}); + +it('throws validation exception when invalid token is used', function (): void { + $user = User::factory()->create(); + + $response = $this->putJson(route('password.reset'), [ + 'email' => $user->email, + 'token' => 'invalid-token', + 'password' => 'NewSecurePassword123!', + 'password_confirmation' => 'NewSecurePassword123!', + ]); + + $response->assertStatus(400) + ->assertJson(['message' => __('passwords.token')]); +}); + +it('throws validation exception when passwords do not match', function (): void { + $user = User::factory()->create(); + + $this->postJson(route('password.email'), ['email' => $user->email]); + + Notification::assertSentTo($user, PasswordResetNotification::class, function (PasswordResetNotification $notification) use ($user) { + $notificationData = $notification->toArray($user); + + $response = $this->putJson(route('password.reset'), [ + 'token' => $notificationData['token'] ?? '', + 'email' => $user->email, + 'password' => 'NewSecurePassword123!', + 'password_confirmation' => 'DifferentPassword123!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('password'); + + return true; + }); +}); diff --git a/tests/Unit/Actions/ResetPasswordTest.php b/tests/Unit/Actions/ResetPasswordTest.php new file mode 100644 index 0000000..f0194ea --- /dev/null +++ b/tests/Unit/Actions/ResetPasswordTest.php @@ -0,0 +1,20 @@ +create(); + $action = app(ResetPassword::class); + + Password::shouldReceive('reset') + ->once() + ->andReturn(Password::PASSWORD_RESET); + + $result = $action->handle('token123', $user->email, 'newpassword123', 'newpassword123'); + + expect($result)->toBe(Password::PASSWORD_RESET); +}); diff --git a/tests/Unit/Actions/SendPasswordResetCodeTest.php b/tests/Unit/Actions/SendPasswordResetCodeTest.php new file mode 100644 index 0000000..27db713 --- /dev/null +++ b/tests/Unit/Actions/SendPasswordResetCodeTest.php @@ -0,0 +1,36 @@ +create(); + $action = app(SendPasswordResetCode::class); + + $action->handle($user->email); + + Notification::assertSentTo($user, PasswordResetNotification::class, function (PasswordResetNotification $notification) use ($user) { + $notificationData = $notification->toArray($user); + $mailable = $notification->toMail($user); + + return isset($notificationData['token']) && + mb_strlen($notificationData['token']) === 64 && + str_contains($mailable->greeting, $user->username); + }); +}); + +it('does not send notification if user does not exist', function (): void { + $action = app(SendPasswordResetCode::class); + + $action->handle('nonexistent@example.com'); + + Notification::assertNothingSent(); +});