-
Notifications
You must be signed in to change notification settings - Fork 11
feat: password reset code for users to use in the CLI #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
403d16d
af95269
59daaaf
e412bc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Actions; | ||
|
|
||
| use App\Models\User; | ||
| use Illuminate\Support\Facades\Hash; | ||
| use Illuminate\Support\Facades\Password; | ||
| use Illuminate\Support\Str; | ||
|
|
||
| final readonly class ResetPassword | ||
| { | ||
| public function handle(string $token, string $email, string $password, string $password_confirmation): string | ||
| { | ||
| $status = Password::reset( | ||
| [ | ||
| 'token' => $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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Actions; | ||
|
|
||
| use App\Models\User; | ||
| use App\Notifications\PasswordResetNotification; | ||
| use Illuminate\Support\Facades\Password; | ||
|
|
||
| final readonly class SendPasswordResetCode | ||
| { | ||
| public function handle(string $email): void | ||
| { | ||
| $user = User::query()->where('email', $email)->first(); | ||
| if (! $user) { | ||
| return; | ||
| } | ||
|
|
||
| $token = Password::createToken($user); | ||
| $user->notify(new PasswordResetNotification($token)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Controllers; | ||
|
|
||
| use App\Actions\ResetPassword; | ||
| use App\Actions\SendPasswordResetCode; | ||
| use App\Http\Requests\ResetPasswordRequest; | ||
| use App\Http\Requests\SendPasswordResetCodeRequest; | ||
| use Illuminate\Http\JsonResponse; | ||
| use Illuminate\Http\Response; | ||
| use Illuminate\Support\Facades\Password; | ||
|
|
||
| final readonly class PasswordResetController | ||
| { | ||
| public function store( | ||
| SendPasswordResetCodeRequest $request, | ||
| SendPasswordResetCode $action | ||
| ): Response { | ||
| $email = $request->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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| final class ResetPasswordRequest extends FormRequest | ||
| { | ||
| /** | ||
| * @return array<string, array<int, string>> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'token' => ['required', 'string'], | ||
| 'email' => ['required', 'email', 'exists:users'], | ||
| 'password' => ['required', 'confirmed', 'min:8'], | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| final class SendPasswordResetCodeRequest extends FormRequest | ||
| { | ||
| /** | ||
| * @return array<string, array<int, string>> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'email' => ['bail', 'required', 'email'], | ||
| ]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,51 @@ | ||||||||||||||||||
| <?php | ||||||||||||||||||
|
|
||||||||||||||||||
| declare(strict_types=1); | ||||||||||||||||||
|
|
||||||||||||||||||
| namespace App\Notifications; | ||||||||||||||||||
|
|
||||||||||||||||||
| use App\Models\User; | ||||||||||||||||||
| use Illuminate\Bus\Queueable; | ||||||||||||||||||
| use Illuminate\Contracts\Queue\ShouldQueue; | ||||||||||||||||||
| use Illuminate\Notifications\Messages\MailMessage; | ||||||||||||||||||
| use Illuminate\Notifications\Notification; | ||||||||||||||||||
|
|
||||||||||||||||||
| final class PasswordResetNotification extends Notification implements ShouldQueue | ||||||||||||||||||
| { | ||||||||||||||||||
| use Queueable; | ||||||||||||||||||
|
|
||||||||||||||||||
| public function __construct( | ||||||||||||||||||
| private readonly string $token | ||||||||||||||||||
| ) {} | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Get the notification's delivery channels. | ||||||||||||||||||
| * | ||||||||||||||||||
| * @return array<int, string> | ||||||||||||||||||
| */ | ||||||||||||||||||
| public function via(User $notifiable): array | ||||||||||||||||||
| { | ||||||||||||||||||
| return ['mail']; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+26
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the
- public function via(User $notifiable): array
+ public function via(object $notifiable): array
{
return ['mail'];
}📝 Committable suggestion
Suggested change
🧰 Tools🪛 PHPMD (2.15.0)26-26: Avoid unused parameters such as '$notifiable'. (undefined) (UnusedFormalParameter) 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| 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<string, mixed> | ||||||||||||||||||
| */ | ||||||||||||||||||
| public function toArray(object $notifiable): array | ||||||||||||||||||
| { | ||||||||||||||||||
| return [ | ||||||||||||||||||
| 'token' => $this->token, | ||||||||||||||||||
| ]; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Models\User; | ||
| use App\Notifications\PasswordResetNotification; | ||
| use Illuminate\Support\Facades\Notification; | ||
|
|
||
| beforeEach(function (): void { | ||
| Notification::fake(); | ||
| }); | ||
|
|
||
| it('can request a reset password link', function (): void { | ||
| $user = User::factory()->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; | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Actions\ResetPassword; | ||
| use App\Models\User; | ||
| use Illuminate\Support\Facades\Password; | ||
|
|
||
| it('may reset password', function (): void { | ||
| $user = User::factory()->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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Actions\SendPasswordResetCode; | ||
| use App\Models\User; | ||
| use App\Notifications\PasswordResetNotification; | ||
| use Illuminate\Support\Facades\Notification; | ||
|
|
||
| beforeEach(function (): void { | ||
| Notification::fake(); | ||
| }); | ||
|
|
||
| it('may send notification with correct code', function (): void { | ||
| $user = User::factory()->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(); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove email existence check to prevent enumeration.
Validating with
exists:usershere also leaks whether an account exists by returning a 422 before the broker runs. Let the broker handle unknown emails (it already maps failures to an opaque message), and keep the response indistinguishable from the valid flow.📝 Committable suggestion
🤖 Prompt for AI Agents