From 13da9b84c17a9a5e3791d8e4da3af98d1821de8e Mon Sep 17 00:00:00 2001 From: ELMEHDI ACHAHED <117928790+Trpsky@users.noreply.github.com> Date: Thu, 29 Jan 2026 04:16:33 +0100 Subject: [PATCH] Feature (#16) * feat: add email verification endpoints and tests Add comprehensive CI/CD pipeline using GitHub Actions - Run Pest tests on PHP 8.3 and 8.4 with MySQL 8.0 - Automated code style checks with Pint - Static analysis with Larastan/PHPStan - Parallel job execution for faster builds - Composer dependency caching - MySQL service container with health checks * feat: implement password reset flow with secure tokens , add forgot password and reset password functionality - Created password_reset_tokens table migration - Added POST /api/v1/forgot-password endpoint - Added POST /api/v1/reset-password endpoint - Both endpoints rate-limited to 6 requests per minute - Integrated with Laravel's Password facade for secure token management - Revokes all user tokens upon successful password reset - Created ForgotPasswordRequest and ResetPasswordRequest with validation - Added comprehensive test suite (6 test cases) Password reset uses signed, time-limited tokens stored in the database. All user sessions are invalidated after successful reset for security. * feat: add reusable API middleware examples , Implement three production-ready middleware patterns for APIs ForceJsonResponse: - Ensures all API responses are JSON formatted - Sets Accept: application/json header automatically - Handles non-JSON responses gracefully LogApiRequests: - Logs API requests with timestamp, method, URL, IP, user ID, status - Tracks and logs response time in milliseconds - Adds X-Response-Time header to all responses - Configurable via APP_LOG_API_REQUESTS env variable EnsureEmailVerified: - Protects routes requiring verified emails - Returns 403 with descriptive message for unverified users - Works with MustVerifyEmail contract All middleware registered as aliases in bootstrap/app.php: 'force.json', 'log.api', 'verified' These provide common API patterns that developers can apply to routes as needed. * docs: update README with new API endpoints , Update API endpoints table with email verification and password reset routes - Added 4 new endpoint rows to API documentation - Updated rate limits for protected routes (60/min -> 120/min) - Documented email verification endpoints - Documented password reset endpoints - All new endpoints properly documented with auth requirements" * Update 0001_01_01_000000_create_users_table.php * Fix test suite configuration and update route names to standard Laravel conventions * style: fix code formatting per Laravel Pint standards * refactor: revert password_reset_tokens back to users create_users migration * Update 0001_01_01_000000_create_users_table.php * fix: resolve all code style and type safety issues Pint fixes: - Fixed concat_space issues - Removed unused imports (MustVerifyEmail from EnsureEmailVerified) - Fixed not_operator_with_successor_space formatting Rector fixes: - Applied EncapsedStringsToSprintfRector for better type safety - All code quality improvements applied PHPStan fixes: - Changed all middleware return types from Response to mixed (Laravel standard) - Added Response type guard in LogApiRequests before accessing methods - Removed redundant instanceof MustVerifyEmail check (User always implements it) --- README.md | 16 +- .../Controllers/Api/V1/AuthController.php | 86 +++++++++- app/Http/Middleware/EnsureEmailVerified.php | 35 ++++ app/Http/Middleware/ForceJsonResponse.php | 38 +++++ app/Http/Middleware/LogApiRequests.php | 49 ++++++ .../Requests/Api/V1/ForgotPasswordRequest.php | 25 +++ .../Api/V1/ResendVerificationRequest.php | 25 +++ .../Requests/Api/V1/ResetPasswordRequest.php | 28 +++ .../Requests/Api/V1/VerifyEmailRequest.php | 24 +++ app/Models/User.php | 4 +- bootstrap/app.php | 9 +- routes/api/v1.php | 16 ++ tests/Feature/Api/V1/AuthTest.php | 2 +- .../Feature/Api/V1/EmailVerificationTest.php | 159 ++++++++++++++++++ tests/Feature/Api/V1/PasswordResetTest.php | 121 +++++++++++++ 15 files changed, 626 insertions(+), 11 deletions(-) create mode 100644 app/Http/Middleware/EnsureEmailVerified.php create mode 100644 app/Http/Middleware/ForceJsonResponse.php create mode 100644 app/Http/Middleware/LogApiRequests.php create mode 100644 app/Http/Requests/Api/V1/ForgotPasswordRequest.php create mode 100644 app/Http/Requests/Api/V1/ResendVerificationRequest.php create mode 100644 app/Http/Requests/Api/V1/ResetPasswordRequest.php create mode 100644 app/Http/Requests/Api/V1/VerifyEmailRequest.php create mode 100644 tests/Feature/Api/V1/EmailVerificationTest.php create mode 100644 tests/Feature/Api/V1/PasswordResetTest.php diff --git a/README.md b/README.md index 4a5427d..6ec2f64 100644 --- a/README.md +++ b/README.md @@ -149,12 +149,16 @@ curl -X POST http://localhost:8080/api/v1/logout \ ### Version 1 (`/api/v1`) -| Method | Endpoint | Auth | Description | Rate Limit | -|--------|-------------|------|--------------------------|------------| -| POST | /register | No | Register new user | 5/min | -| POST | /login | No | Get authentication token | 5/min | -| POST | /logout | Yes | Revoke current token | 60/min | -| GET | /me | Yes | Get current user profile | 60/min | +| Method | Endpoint | Auth | Description | Rate Limit | +|--------|------------------------------|------|-------------------------------|------------| +| POST | /register | No | Register new user | 5/min | +| POST | /login | No | Get authentication token | 5/min | +| POST | /logout | Yes | Revoke current token | 120/min | +| GET | /me | Yes | Get current user profile | 120/min | +| POST | /email/verify/{id}/{hash} | Yes | Verify email address | 120/min | +| POST | /email/resend | Yes | Resend verification email | 6/min | +| POST | /forgot-password | No | Request password reset link | 6/min | +| POST | /reset-password | No | Reset password with token | 6/min | ## Response Format diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php index 88a188e..cba9c33 100644 --- a/app/Http/Controllers/Api/V1/AuthController.php +++ b/app/Http/Controllers/Api/V1/AuthController.php @@ -5,13 +5,20 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Api\ApiController; +use App\Http\Requests\Api\V1\ForgotPasswordRequest; use App\Http\Requests\Api\V1\LoginRequest; use App\Http\Requests\Api\V1\RegisterRequest; +use App\Http\Requests\Api\V1\ResendVerificationRequest; +use App\Http\Requests\Api\V1\ResetPasswordRequest; +use App\Http\Requests\Api\V1\VerifyEmailRequest; use App\Http\Resources\UserResource; use App\Models\User; +use Illuminate\Auth\Events\PasswordReset; +use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; final class AuthController extends ApiController { @@ -23,12 +30,14 @@ final class AuthController extends ApiController 'password' => Hash::make($request->password), ]); + $user->sendEmailVerificationNotification(); + $token = $user->createToken('auth-token')->plainTextToken; return $this->created([ 'user' => new UserResource($user), 'token' => $token, - ], 'User registered successfully'); + ], 'User registered successfully. Please check your email to verify your account.'); } public function login(LoginRequest $request): JsonResponse @@ -60,4 +69,79 @@ final class AuthController extends ApiController { return $this->success(new UserResource($request->user())); } + + public function verifyEmail(VerifyEmailRequest $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + if ($user->hasVerifiedEmail()) { + return $this->success(message: 'Email already verified'); + } + + if ($user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return $this->success(message: 'Email verified successfully'); + } + + public function resendVerificationEmail(ResendVerificationRequest $request): JsonResponse + { + $user = User::query()->where('email', $request->email)->first(); + + if (! $user) { + return $this->notFound('User not found'); + } + + if ($user->hasVerifiedEmail()) { + return $this->error('Email already verified', 400); + } + + $user->sendEmailVerificationNotification(); + + return $this->success(message: 'Verification email sent successfully'); + } + + public function forgotPassword(ForgotPasswordRequest $request): JsonResponse + { + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status === Password::RESET_LINK_SENT) { + return $this->success(message: 'Password reset link sent to your email'); + } + + return $this->error('Unable to send reset link', 500); + } + + public function resetPassword(ResetPasswordRequest $request): JsonResponse + { + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + ])->save(); + + $user->tokens()->delete(); + + event(new PasswordReset($user)); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return $this->success(message: 'Password reset successfully'); + } + + return $this->error( + match ($status) { + Password::INVALID_TOKEN => 'Invalid or expired reset token', + Password::INVALID_USER => 'User not found', + default => 'Unable to reset password', + }, + 400 + ); + } } diff --git a/app/Http/Middleware/EnsureEmailVerified.php b/app/Http/Middleware/EnsureEmailVerified.php new file mode 100644 index 0000000..dc760ba --- /dev/null +++ b/app/Http/Middleware/EnsureEmailVerified.php @@ -0,0 +1,35 @@ +user(); + + if (! $user) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthenticated', + ], 401); + } + + if (! $user->hasVerifiedEmail()) { + return response()->json([ + 'success' => false, + 'message' => 'Your email address is not verified. Please verify your email to continue.', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ForceJsonResponse.php b/app/Http/Middleware/ForceJsonResponse.php new file mode 100644 index 0000000..91790a4 --- /dev/null +++ b/app/Http/Middleware/ForceJsonResponse.php @@ -0,0 +1,38 @@ +headers->set('Accept', 'application/json'); + + $response = $next($request); + + if ($response instanceof JsonResponse) { + return $response; + } + + // Convert non-JSON responses to JSON + if ($response instanceof Response) { + return response()->json([ + 'success' => false, + 'message' => 'An error occurred', + 'data' => $response->getContent(), + ], $response->getStatusCode()); + } + + return $response; + } +} diff --git a/app/Http/Middleware/LogApiRequests.php b/app/Http/Middleware/LogApiRequests.php new file mode 100644 index 0000000..e24ae12 --- /dev/null +++ b/app/Http/Middleware/LogApiRequests.php @@ -0,0 +1,49 @@ + now()->toIso8601String(), + 'method' => $request->method(), + 'url' => $request->fullUrl(), + 'ip' => $request->ip(), + 'user_id' => $request->user()?->id, + 'status' => $response->getStatusCode(), + 'duration_ms' => $duration, + 'user_agent' => $request->userAgent(), + ]); + } + + // Add performance header + $response->headers->set('X-Response-Time', $duration.'ms'); + + return $response; + } +} diff --git a/app/Http/Requests/Api/V1/ForgotPasswordRequest.php b/app/Http/Requests/Api/V1/ForgotPasswordRequest.php new file mode 100644 index 0000000..d6c49ff --- /dev/null +++ b/app/Http/Requests/Api/V1/ForgotPasswordRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'exists:users,email'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/ResendVerificationRequest.php b/app/Http/Requests/Api/V1/ResendVerificationRequest.php new file mode 100644 index 0000000..680d830 --- /dev/null +++ b/app/Http/Requests/Api/V1/ResendVerificationRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'exists:users,email'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/ResetPasswordRequest.php b/app/Http/Requests/Api/V1/ResetPasswordRequest.php new file mode 100644 index 0000000..7e41e93 --- /dev/null +++ b/app/Http/Requests/Api/V1/ResetPasswordRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/VerifyEmailRequest.php b/app/Http/Requests/Api/V1/VerifyEmailRequest.php new file mode 100644 index 0000000..2276920 --- /dev/null +++ b/app/Http/Requests/Api/V1/VerifyEmailRequest.php @@ -0,0 +1,24 @@ +user() && (int) $this->route('id') === $this->user()->id; + } + + /** + * @return array + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index bb4a434..c8f3000 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -21,7 +21,7 @@ use Laravel\Sanctum\HasApiTokens; * @property Carbon|null $created_at * @property Carbon|null $updated_at */ -final class User extends Authenticatable +final class User extends Authenticatable implements MustVerifyEmail { use HasApiTokens; diff --git a/bootstrap/app.php b/bootstrap/app.php index 72ab5bf..08b5c07 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Http\Middleware\EnsureEmailVerified; +use App\Http\Middleware\ForceJsonResponse; +use App\Http\Middleware\LogApiRequests; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -14,7 +17,11 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'force.json' => ForceJsonResponse::class, + 'log.api' => LogApiRequests::class, + 'verified' => EnsureEmailVerified::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/routes/api/v1.php b/routes/api/v1.php index f4737b2..151fab8 100644 --- a/routes/api/v1.php +++ b/routes/api/v1.php @@ -24,4 +24,20 @@ Route::middleware('throttle:auth')->group(function (): void { Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function (): void { Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout'); Route::get('me', [AuthController::class, 'me'])->name('api.v1.me'); + + // Email verification + Route::post('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail']) + ->middleware('signed') + ->name('verification.verify'); + Route::post('email/resend', [AuthController::class, 'resendVerificationEmail']) + ->middleware('throttle:6,1') + ->name('verification.send'); +}); + +// Password reset routes (public with rate limiting) +Route::middleware('throttle:6,1')->group(function (): void { + Route::post('forgot-password', [AuthController::class, 'forgotPassword']) + ->name('password.email'); + Route::post('reset-password', [AuthController::class, 'resetPassword']) + ->name('password.reset'); }); diff --git a/tests/Feature/Api/V1/AuthTest.php b/tests/Feature/Api/V1/AuthTest.php index e7f2f58..7b9b0fb 100644 --- a/tests/Feature/Api/V1/AuthTest.php +++ b/tests/Feature/Api/V1/AuthTest.php @@ -27,7 +27,7 @@ describe('Registration', function (): void { ]) ->assertJson([ 'success' => true, - 'message' => 'User registered successfully', + 'message' => 'User registered successfully. Please check your email to verify your account.', ]); $this->assertDatabaseHas('users', [ diff --git a/tests/Feature/Api/V1/EmailVerificationTest.php b/tests/Feature/Api/V1/EmailVerificationTest.php new file mode 100644 index 0000000..76bb275 --- /dev/null +++ b/tests/Feature/Api/V1/EmailVerificationTest.php @@ -0,0 +1,159 @@ +create(['email_verified_at' => null]); + $token = $user->createToken('test-token')->plainTextToken; + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($verificationUrl); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Email verified successfully', + ]); + + $this->assertNotNull($user->fresh()->email_verified_at); + Event::assertDispatched(Verified::class); + }); + + it('returns success if email is already verified', function (): void { + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + $token = $user->createToken('test-token')->plainTextToken; + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($verificationUrl); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Email already verified', + ]); + }); + + it('fails verification without authentication', function (): void { + $user = User::factory()->create(['email_verified_at' => null]); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->postJson($verificationUrl); + + $response->assertStatus(401); + }); + + it('fails verification with invalid signature', function (): void { + $user = User::factory()->create(['email_verified_at' => null]); + $token = $user->createToken('test-token')->plainTextToken; + + // Invalid URL without signature + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson(sprintf('/api/v1/email/verify/%d/invalid-hash', $user->id)); + + $response->assertStatus(403); + }); +}); + +describe('Resend Verification Email', function (): void { + it('resends verification email successfully', function (): void { + $user = User::factory()->create(['email_verified_at' => null]); + $token = $user->createToken('test-token')->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/email/resend', [ + 'email' => $user->email, + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Verification email sent successfully', + ]); + }); + + it('fails to resend if email is already verified', function (): void { + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + $token = $user->createToken('test-token')->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/email/resend', [ + 'email' => $user->email, + ]); + + $response->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'Email already verified', + ]); + }); + + it('fails with invalid email', function (): void { + $user = User::factory()->create(); + $token = $user->createToken('test-token')->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/email/resend', [ + 'email' => 'nonexistent@example.com', + ]); + + $response->assertStatus(422); + }); + + it('requires authentication', function (): void { + $user = User::factory()->create(['email_verified_at' => null]); + + $response = $this->postJson('/api/v1/email/resend', [ + 'email' => $user->email, + ]); + + $response->assertStatus(401); + }); + + it('respects rate limiting', function (): void { + $user = User::factory()->create(['email_verified_at' => null]); + $token = $user->createToken('test-token')->plainTextToken; + + // Make 7 requests (limit is 6 per minute) + for ($i = 0; $i < 7; $i++) { + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/email/resend', [ + 'email' => $user->email, + ]); + } + + // Last request should be rate limited + $response->assertStatus(429); + }); +}); diff --git a/tests/Feature/Api/V1/PasswordResetTest.php b/tests/Feature/Api/V1/PasswordResetTest.php new file mode 100644 index 0000000..8ac8d73 --- /dev/null +++ b/tests/Feature/Api/V1/PasswordResetTest.php @@ -0,0 +1,121 @@ +create(); + + $response = $this->postJson('/api/v1/forgot-password', [ + 'email' => $user->email, + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Password reset link sent to your email', + ]); + }); + + it('fails with non-existent email', function (): void { + $response = $this->postJson('/api/v1/forgot-password', [ + 'email' => 'nonexistent@example.com', + ]); + + $response->assertStatus(422); + }); + + it('respects rate limiting', function (): void { + $user = User::factory()->create(); + + // Make 7 requests (limit is 6 per minute) + for ($i = 0; $i < 7; $i++) { + $response = $this->postJson('/api/v1/forgot-password', [ + 'email' => $user->email, + ]); + } + + // Last request should be rate limited + $response->assertStatus(429); + }); +}); + +describe('Reset Password', function (): void { + it('resets password successfully with valid token', function (): void { + $user = User::factory()->create(); + + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/reset-password', [ + 'email' => $user->email, + 'token' => $token, + 'password' => 'newpassword123', + 'password_confirmation' => 'newpassword123', + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Password reset successfully', + ]); + + // Verify password was changed + $this->assertTrue(Hash::check('newpassword123', $user->fresh()->password)); + + // Verify all tokens were deleted + $this->assertDatabaseCount('personal_access_tokens', 0); + }); + + it('fails with invalid token', function (): void { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/reset-password', [ + 'email' => $user->email, + 'token' => 'invalid-token', + 'password' => 'newpassword123', + 'password_confirmation' => 'newpassword123', + ]); + + $response->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'Invalid or expired reset token', + ]); + }); + + it('fails with mismatched passwords', function (): void { + $user = User::factory()->create(); + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/reset-password', [ + 'email' => $user->email, + 'token' => $token, + 'password' => 'newpassword123', + 'password_confirmation' => 'differentpassword', + ]); + + $response->assertStatus(422); + }); + + it('fails with non-existent email', function (): void { + $response = $this->postJson('/api/v1/reset-password', [ + 'email' => 'nonexistent@example.com', + 'token' => 'some-token', + 'password' => 'newpassword123', + 'password_confirmation' => 'newpassword123', + ]); + + $response->assertStatus(400) + ->assertJson([ + 'success' => false, + 'message' => 'User not found', + ]); + }); +});