Skip to content

Commit b3b1ad9

Browse files
committed
feat: Add email verification for new users
Implement Laravel's MustVerifyEmail, verification routes, and protected middleware to ensure users verify their email before accessing protected routes. Improves security and prevents fake sign-ups. Implement email.verified middleware (EnsureEmailIsVerified Middleware) Updated the test to factor in email verification
1 parent dcc505f commit b3b1ad9

File tree

8 files changed

+129
-15
lines changed

8 files changed

+129
-15
lines changed

app/Http/Controllers/Api/V1/Auth/AuthController.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Models\User;
77
use App\Models\Organisation;
88
use App\Services\OrganisationService;
9+
use Illuminate\Auth\Events\Registered;
910
use Illuminate\Http\Request;
1011
use Illuminate\Support\Facades\DB;
1112
use Illuminate\Support\Facades\Hash;
@@ -67,7 +68,7 @@ public function store(Request $request)
6768
$token = JWTAuth::fromUser($user);
6869

6970
$is_superadmin = in_array($user->role, ['admin']);
70-
71+
7172
DB::commit();
7273

7374
$email_template_id = null;
@@ -77,6 +78,8 @@ public function store(Request $request)
7778
$email_template_id = $emailTemplate->id;
7879
}
7980

81+
event(new Registered($user));
82+
8083
return response()->json([
8184
'status_code' => 201,
8285
'message' => 'User Created Successfully',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Foundation\Auth\EmailVerificationRequest;
7+
use Illuminate\Http\Request;
8+
9+
class VerificationController extends Controller
10+
{
11+
//
12+
13+
14+
public function emailNotice($request)
15+
{
16+
17+
return response()->json([
18+
'status_code' => 403,
19+
'message' => 'Email not verified. Please verify your email to continue.',
20+
'status' => 'error',
21+
'data' => []
22+
], 403);
23+
}
24+
25+
public function verifyEmail(EmailVerificationRequest $request)
26+
{
27+
$request->fulfill();
28+
29+
return response()->json([
30+
'status_code' => 200,
31+
'message' => 'Email verified successfully',
32+
'status' => 'success',
33+
'data' => []
34+
], 200);
35+
}
36+
37+
public function resendEmail(Request $request)
38+
{
39+
$user = $request->user();
40+
41+
if ($user->hasVerifiedEmail()) {
42+
return response()->json([
43+
'status_code' => 400,
44+
'message' => 'Email already verified.',
45+
'status' => 'error',
46+
'data' => []
47+
], 400);
48+
}
49+
50+
$user->sendEmailVerificationNotification();
51+
52+
return response()->json([
53+
'status_code' => 200,
54+
'message' => 'Verification email resent successfully.',
55+
'status' => 'success',
56+
'data' => []
57+
], 200);
58+
}
59+
}

app/Http/Kernel.php

+1
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,6 @@ class Kernel extends HttpKernel
7070
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
7171
'admin' => \App\Http\Middleware\AdminMiddleware::class,
7272
'superadmin' => \App\Http\Middleware\SuperAdminMiddleware::class,
73+
'email.verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
7374
];
7475
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Symfony\Component\HttpFoundation\Response;
8+
9+
class EnsureEmailIsVerified
10+
{
11+
/**
12+
* Handle an incoming request.
13+
*
14+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
15+
*/
16+
public function handle(Request $request, Closure $next): Response
17+
{
18+
// Ensure user is authenticated
19+
dump($request->user);
20+
if (! $request->user() || ! $request->user()->hasVerifiedEmail()) {
21+
return response()->json([
22+
'status_code' => 403,
23+
'message' => 'Email not verified. Please verify your email to continue.',
24+
'status' => 'error',
25+
'data' => []
26+
], 403);
27+
}
28+
29+
return $next($request);
30+
}
31+
}

app/Models/User.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Models;
44

5+
use Illuminate\Contracts\Auth\MustVerifyEmail;
56
use Illuminate\Database\Eloquent\Factories\HasFactory;
67
use Illuminate\Database\Eloquent\SoftDeletes;
78
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -15,7 +16,7 @@
1516
use Illuminate\Auth\Passwords\CanResetPassword;
1617
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
1718

18-
class User extends Authenticatable implements JWTSubject, CanResetPasswordContract
19+
class User extends Authenticatable implements JWTSubject, CanResetPasswordContract, MustVerifyEmail
1920
{
2021
use HasApiTokens, HasFactory, Notifiable, HasUuids, CanResetPassword, SoftDeletes;
2122

@@ -41,6 +42,7 @@ class User extends Authenticatable implements JWTSubject, CanResetPasswordContr
4142
'email',
4243
'password',
4344
'role',
45+
'email_verified_at'
4446
];
4547

4648
/* protected $fillable = [

routes/api.php

+21-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Controllers\Api\V1\Auth\VerificationController;
34
use Illuminate\Support\Facades\Route;
45
use App\Http\Controllers\QuestController;
56
use App\Http\Controllers\Api\V1\JobController;
@@ -77,6 +78,13 @@
7778
Route::post('/api-status', [ApiStatusCheckerController::class, 'store']);
7879

7980
Route::post('/auth/register', [AuthController::class, 'store']);
81+
82+
83+
Route::get('/auth/email/verify', [VerificationController::class, 'emailNotice'])->middleware('auth')->name('verification.notice');
84+
Route::get('/auth/email/verify/{id}/{hash}', [VerificationController::class,'verifyEmail'])->middleware(['auth', 'signed'])->name('verification.verify');
85+
Route::post('/auth/email/verification-notification', [VerificationController::class, 'resendEmail'])->middleware(['auth', 'throttle:6,1'])->name('verification.send');
86+
87+
8088
Route::post('/auth/login', [LoginController::class, 'login']);
8189
Route::post('/auth/logout', [LoginController::class, 'logout'])->middleware('auth:api');
8290
Route::post('/auth/password-reset-email', ForgetPasswordRequestController::class)->name('password.reset');
@@ -96,7 +104,7 @@
96104
Route::get('/auth/facebook/callback', [SocialAuthController::class, 'callbackFromFacebook']);
97105
Route::post('/auth/facebook/callback', [SocialAuthController::class, 'saveFacebookRequest']);
98106

99-
Route::middleware('auth:api')->group(function () {
107+
Route::middleware(['auth:api', 'email.verified'])->group(function () {
100108

101109
Route::get('/users/stats', [UserController::class, 'stats']);
102110
Route::apiResource('/users', UserController::class);
@@ -123,7 +131,7 @@
123131

124132
Route::middleware('throttle:10,1')->get('/topics/search', [ArticleController::class, 'search']);
125133

126-
Route::middleware('auth:api')->group(function () {
134+
Route::middleware(['auth:api', 'email.verified'])->group(function () {
127135

128136
Route::get('/organisations/{orgId}/products/search', [ProductController::class, 'search']);
129137

@@ -141,7 +149,7 @@
141149

142150

143151
//comment
144-
Route::middleware('auth:api')->group(function () {
152+
Route::middleware(['auth:api', 'email.verified'])->group(function () {
145153
Route::post('/blogs/{blogId}/comments', [CommentController::class, 'createComment']);
146154
Route::post('/comments/{commentId}/reply', [CommentController::class, 'replyComment']);
147155
Route::post('/comments/{commentId}/like', [CommentController::class, 'likeComment']);
@@ -179,14 +187,14 @@
179187
Route::get('/squeeze-pages-users', [SqueezePageUserController::class, 'index']);
180188

181189

182-
Route::middleware(['auth:api', 'admin'])->group(function () {
190+
Route::middleware(['auth:api', 'admin', 'email.verified'])->group(function () {
183191
Route::get('/email-templates', [EmailTemplateController::class, 'index']);
184192
Route::post('/email-templates', [EmailTemplateController::class, 'store']);
185193
Route::patch('/email-templates/{id}', [EmailTemplateController::class, 'update']);
186194
Route::delete('/email-templates/{id}', [EmailTemplateController::class, 'destroy']);
187195
});
188196

189-
Route::middleware(['auth:api', 'admin'])->group(function () {
197+
Route::middleware(['auth:api', 'admin', 'email.verified'])->group(function () {
190198
// Dashboard
191199
Route::get('/users-list', [AdminDashboardController::class, 'getUsers']);
192200
});
@@ -199,7 +207,7 @@
199207
Route::post('/invite', [InvitationAcceptanceController::class, 'acceptInvitationPost']);
200208

201209

202-
Route::middleware('auth:api')->group(function () {
210+
Route::middleware(['auth:api', 'email.verified'])->group(function () {
203211

204212
// Subscriptions, Plans and Features
205213
Route::apiResource('/features', FeatureController::class);
@@ -275,10 +283,10 @@
275283
});
276284
Route::get('/notification-settings', [NotificationSettingController::class, 'show']);
277285

278-
Route::middleware(['auth:api', 'admin'])->get('/customers', [CustomerController::class, 'index']);
286+
Route::middleware(['auth:api', 'admin', 'email.verified'])->get('/customers', [CustomerController::class, 'index']);
279287

280288
//Blogs
281-
Route::group(['middleware' => ['auth.jwt', 'admin']], function () {
289+
Route::group(['middleware' => ['auth.jwt', 'email.verified' ,'admin']], function () {
282290
Route::post('/blogs', [BlogController::class, 'store']);
283291
Route::patch('/blogs/edit/{id}', [BlogController::class, 'update'])->name('admin.blogs.update');
284292
Route::delete('/blogs/{id}', [BlogController::class, 'destroy']);
@@ -304,7 +312,7 @@
304312
Route::get('/blogs/{id}', [BlogController::class, 'show']);
305313
Route::get('/blogs', [BlogController::class, 'index']);
306314

307-
Route::group(['middleware' => ['auth:api']], function () {
315+
Route::group(['middleware' => ['auth:api', 'email.verified']], function () {
308316
Route::post('/user/preferences', [PreferenceController::class, 'store']);
309317
Route::put('/user/preferences/{id}', [PreferenceController::class, 'update']);
310318
Route::get('/user/preferences', [PreferenceController::class, 'index']);
@@ -315,15 +323,15 @@
315323
});
316324

317325
//region get and update
318-
Route::group(['middleware' => ['auth:api']], function () {
326+
Route::group(['middleware' => ['auth:api', 'email.verified']], function () {
319327
Route::put('/regions/{user_id}', [PreferenceController::class, 'updateRegion']);
320328
Route::get('/regions/{user_id}', [PreferenceController::class, 'showRegion']);
321329
});
322330
// Notification settings
323331
Route::patch('/notification-settings/{user_id}', [NotificationPreferenceController::class, 'update']);
324332

325333

326-
Route::middleware(['auth:api', 'admin'])->group(function () {
334+
Route::middleware(['auth:api', 'email.verified', 'admin'])->group(function () {
327335
//Email Template
328336
Route::apiResource('email-templates', EmailTemplateController::class);
329337
});
@@ -339,8 +347,8 @@
339347
// User Notification
340348
Route::patch('/notifications/{notification}', [UserNotificationController::class, 'update']);
341349
Route::delete('/notifications', [UserNotificationController::class, 'destroy']);
342-
Route::post('/notifications', [UserNotificationController::class, 'create'])->middleware('auth.jwt');
343-
Route::get('/notifications', [UserNotificationController::class, 'getByUser'])->middleware('auth.jwt');
350+
Route::post('/notifications', [UserNotificationController::class, 'create'])->middleware(['auth.jwt', 'email.verified']);
351+
Route::get('/notifications', [UserNotificationController::class, 'getByUser'])->middleware(['auth.jwt', 'email.verified']);
344352
//Timezone
345353
Route::get('/timezones', [TimezoneController::class, 'index']);
346354

tests/Feature/InvitationTest.php

+5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public function setUp(): void
2929
'last_name' => 'User',
3030
]);
3131

32+
//pseudo email verification
33+
$user = User::where('email', 'test@example.com')->first();
34+
$user->email_verified_at = now();
35+
$user->save();
36+
3237
// Login the user
3338
$loginResponse = $this->postJson('/api/v1/auth/login', [
3439
'email' => 'test@example.com',

tests/Feature/UserDeletionTest.php

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function test_user_not_found_during_deletion()
2525
'password' => bcrypt('@Bulldozer01'), // Admin password
2626
'role' => 'admin',
2727
'is_verified' => 1,
28+
'email_verified_at' => now()
2829
]);
2930

3031
// Attempt to delete a non-existent user
@@ -51,6 +52,7 @@ public function test_unauthorized_user_deleting_another_user()
5152
'password' => bcrypt('password'),
5253
'role' => 'user',
5354
'is_verified' => 1,
55+
'email_verified_at' => now()
5456
]);
5557

5658
$anotherUser = User::create([
@@ -86,6 +88,7 @@ public function test_admin_deleting_user()
8688
'password' => bcrypt('@Bulldozer01'), // Admin password
8789
'role' => 'admin',
8890
'is_verified' => 1,
91+
'email_verified_at' => now()
8992
]);
9093

9194
$user = User::create([
@@ -122,6 +125,7 @@ public function test_super_admin_deleting_user()
122125
'password' => bcrypt('@Bulldozer01'),
123126
'role' => 'superAdmin',
124127
'is_verified' => 1,
128+
'email_verified_at' => now()
125129
]);
126130

127131
$anotherUser = User::create([
@@ -158,6 +162,7 @@ public function test_deleting_self_as_super_admin()
158162
'password' => bcrypt('@Bulldozer01'),
159163
'role' => 'superAdmin',
160164
'is_verified' => 1,
165+
'email_verified_at' => now()
161166
]);
162167

163168
// Delete self as a super admin

0 commit comments

Comments
 (0)