diff --git a/.github/assets/swagger,yml b/.github/assets/swagger,yml index dd204899..9ed39785 100644 --- a/.github/assets/swagger,yml +++ b/.github/assets/swagger,yml @@ -1000,6 +1000,79 @@ paths: security: - BearerAuth: [] + /reset_password: + post: + tags: + - Auth + summary: Start password reset + description: Send a password reset link to the user's email. + requestBody: + content: + application/json: + schema: + type: object + required: + - email + - reset_url + properties: + email: + type: string + format: email + reset_url: + type: string + format: uri + description: URL of the password reset form. + responses: + "204": + description: Reset email successful send + "500": + description: Unable to send reset link + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /reset_password/{token}: + post: + tags: + - Auth + summary: Reset password + description: Reset the user's password. + parameters: + - name: token + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + required: + - email + - password + - password_confirmation + properties: + email: + type: string + format: email + password: + type: string + format: password + password_confirmation: + type: string + format: password + responses: + "204": + description: Password reset successfully + "500": + description: Unable to send reset link + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: schemas: Show: @@ -1180,4 +1253,3 @@ components: ." name: Authorization in: header -x-original-swagger-version: "2.0" diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php new file mode 100644 index 00000000..66ea51da --- /dev/null +++ b/app/Http/Controllers/PasswordResetController.php @@ -0,0 +1,73 @@ +validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'reset_url' => ['required', 'url'] + ]); + + ResetPassword::createUrlUsing(function (User $user, string $token) use ($request) { + return $request->reset_url . '/' . $token . '?email=' . $user->email; + }); + + $status = Password::sendResetLink( + $request->only('email') + ); + + return $status === Password::RESET_LINK_SENT + ? new ApiSuccessResponse('', Response::HTTP_NO_CONTENT) + : new ApiErrorResponse('Unable to send reset link'); + } + + /** + * Reset the user's password. + * + * @param \Illuminate\Http\Request $request + * @return \App\Http\Responses\ApiSuccessResponse|\App\Http\Responses\ApiErrorResponse + */ + public function reset(Request $request, string $token) + { + $request->merge(['token' => $token]); + $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'email', 'exists:users,email'], + 'password' => ['required', 'min:8', 'confirmed'] + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password) { + $user->forceFill([ + 'password' => Hash::make($password) + ])->save(); + + $user->tokens()->delete(); + } + ); + + return $status === Password::PASSWORD_RESET + ? new ApiSuccessResponse('', Response::HTTP_NO_CONTENT) + : new ApiErrorResponse('Unable to reset password'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 409a46bb..db53c86f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,8 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; + +use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -11,7 +13,7 @@ class User extends Authenticatable { - use HasApiTokens, HasFactory, HasRoles, Notifiable; + use HasApiTokens, HasFactory, HasRoles, Notifiable, CanResetPassword; /** * The attributes that are mass assignable. diff --git a/routes/api/v1/auth.php b/routes/api/v1/auth.php index 85f1effb..e88dd51c 100644 --- a/routes/api/v1/auth.php +++ b/routes/api/v1/auth.php @@ -1,7 +1,11 @@ middleware('auth:sanctum'); + +Route::post('/reset_password', [PasswordResetController::class, 'sendLink'])->name('api.v1.reset-password.email'); +Route::post('/reset_password/{token}', [PasswordResetController::class, 'reset'])->name('api.v1.reset-password.reset'); diff --git a/tests/Feature/Http/Controllers/PasswordResetControllerTest.php b/tests/Feature/Http/Controllers/PasswordResetControllerTest.php new file mode 100644 index 00000000..47a3bd12 --- /dev/null +++ b/tests/Feature/Http/Controllers/PasswordResetControllerTest.php @@ -0,0 +1,112 @@ +create(); + $email = $user->email; + + $response = $this->postJson('/api/v1/reset_password', ['email' => $email, 'reset_url' => 'http://localhost']); + + $this->assertDatabaseHas('password_reset_tokens', ['email' => $email]); + + $response->assertStatus(Response::HTTP_NO_CONTENT); + } + + /** + * Test sending a password reset link with invalid email. + */ + public function test_send_link_with_invalid_email(): void + { + $response = $this->postJson('/api/v1/reset_password', ['email' => 'invalid@example.com', 'reset_url' => 'http://localhost']); + + $this->assertDatabaseMissing('password_reset_tokens', ['email' => 'invalid@example.com']); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** + * Test resetting the user's password. + */ + public function test_reset_password(): void + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/reset_password/' . $token, [ + 'email' => $user->email, + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]); + + $response->assertStatus(Response::HTTP_NO_CONTENT); + $this->assertTrue(Hash::check('newpassword', $user->fresh()->password)); + } + + /** + * Test resetting the user's password with invalid token. + */ + public function test_reset_password_with_invalid_token(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/reset_password/invalid_token', [ + 'email' => $user->email, + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]); + + $response->assertStatus(Response::HTTP_INTERNAL_SERVER_ERROR); + $this->assertFalse(Hash::check('newpassword', $user->fresh()->password)); + } + + /** + * Test resetting the user's password with invalid email. + */ + public function test_reset_password_with_invalid_email(): void + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/reset_password/' . $token, [ + 'email' => 'invalid@example.com', + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** + * Test resetting the user's password with password mismatch. + */ + public function test_reset_password_with_password_mismatch(): void + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/reset_password/' . $token, [ + 'email' => $user->email, + 'password' => 'newpassword', + 'password_confirmation' => 'mismatchedpassword', + ]); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertFalse(Hash::check('newpassword', $user->fresh()->password)); + } +}