Skip to content

Commit

Permalink
feat(auth): add password reset endpoints (#89)
Browse files Browse the repository at this point in the history
Implement password reset endpoints for enabling the "forgot password"
functionality. Users can now initiate the password reset process,
receive a reset link, and securely update their password. Enhances
overall security and user experience.

Signed-off-by: Valentin Sickert <[email protected]>
  • Loading branch information
Lapotor authored Dec 23, 2023
1 parent c825a41 commit 186f627
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 2 deletions.
74 changes: 73 additions & 1 deletion .github/assets/swagger,yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1180,4 +1253,3 @@ components:
."
name: Authorization
in: header
x-original-swagger-version: "2.0"
73 changes: 73 additions & 0 deletions app/Http/Controllers/PasswordResetController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace App\Http\Controllers;

use App\Http\Responses\ApiErrorResponse;
use App\Http\Responses\ApiSuccessResponse;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;

class PasswordResetController extends Controller
{
/**
* Send a password reset link to the user's email.
*
* @param \Illuminate\Http\Request $request
* @return \App\Http\Responses\ApiSuccessResponse|\App\Http\Responses\ApiErrorResponse
*/
public function sendLink(Request $request)
{
$request->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');
}
}
4 changes: 3 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions routes/api/v1/auth.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<?php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\PasswordResetController;
use Illuminate\Support\Facades\Route;

Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->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');
112 changes: 112 additions & 0 deletions tests/Feature/Http/Controllers/PasswordResetControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace Tests\Feature\Http\Controllers;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Tests\TestCase;

class PasswordResetControllerTest extends TestCase
{
use RefreshDatabase;

/**
* Test sending a password reset link.
*/
public function test_send_link(): void
{
$user = User::factory()->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' => '[email protected]', 'reset_url' => 'http://localhost']);

$this->assertDatabaseMissing('password_reset_tokens', ['email' => '[email protected]']);

$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' => '[email protected]',
'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));
}
}

0 comments on commit 186f627

Please sign in to comment.