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', + ]); + }); +});