Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions app/Actions/ResetPassword.php
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;
}
}
23 changes: 23 additions & 0 deletions app/Actions/SendPasswordResetCode.php
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));
}
}
47 changes: 47 additions & 0 deletions app/Http/Controllers/PasswordResetController.php
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);
}
}
22 changes: 22 additions & 0 deletions app/Http/Requests/ResetPasswordRequest.php
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'],
Comment on lines +17 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove email existence check to prevent enumeration.

Validating with exists:users here 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.

-            'email' => ['required', 'email', 'exists:users'],
+            'email' => ['required', 'email'],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'token' => ['required', 'string'],
'email' => ['required', 'email', 'exists:users'],
'password' => ['required', 'confirmed', 'min:8'],
'token' => ['required', 'string'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', 'min:8'],
🤖 Prompt for AI Agents
In app/Http/Requests/ResetPasswordRequest.php around lines 17 to 19, the
validation currently includes an 'exists:users' rule for the email which leaks
account existence; remove the 'exists:users' rule from the email validation
array so only format is validated (e.g., keep 'required' and 'email'), letting
the password broker handle unknown emails and preserve an indistinguishable
response.

];
}
}
20 changes: 20 additions & 0 deletions app/Http/Requests/SendPasswordResetCodeRequest.php
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'],
];
}
}
51 changes: 51 additions & 0 deletions app/Notifications/PasswordResetNotification.php
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix the via signature to match the base class.

Notification::via() declares object $notifiable; narrowing it to User causes a fatal “Declaration ... must be compatible” error. Keep the parameter typed as object (or untyped) and add a docblock if you need stronger hints.

-    public function via(User $notifiable): array
+    public function via(object $notifiable): array
     {
         return ['mail'];
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function via(User $notifiable): array
{
return ['mail'];
}
public function via(object $notifiable): array
{
return ['mail'];
}
🧰 Tools
🪛 PHPMD (2.15.0)

26-26: Avoid unused parameters such as '$notifiable'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
In app/Notifications/PasswordResetNotification.php around lines 26 to 29, the
method signature public function via(User $notifiable): array narrows the
parameter type and is incompatible with the base Notification::via(object
$notifiable); change the signature to accept object (e.g. public function
via(object $notifiable): array) and, if you need stronger IDE/type hints, add a
docblock like @param User|object $notifiable above the method to document the
expected User type.


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,
];
}
}
5 changes: 5 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');

Expand Down
77 changes: 77 additions & 0 deletions tests/Feature/Http/PasswordResetControllerTest.php
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;
});
});
20 changes: 20 additions & 0 deletions tests/Unit/Actions/ResetPasswordTest.php
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);
});
36 changes: 36 additions & 0 deletions tests/Unit/Actions/SendPasswordResetCodeTest.php
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();
});