DONE: replace email verification by link instead of otp

This commit is contained in:
ISMAIL MASSERAN
2026-07-12 10:03:31 +08:00
parent 1e50e3d19f
commit 431bbee17c
30 changed files with 263 additions and 593 deletions
+4
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -87,3 +88,6 @@ AUTH_COOKIE_EXPOSE_TOKEN=false
ACTIVE_ROLE_PREFER_MEMBER=true
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
@@ -12,7 +12,6 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
use Modules\Auth\Entities\User;
use Modules\Role\Entities\Role;
use Modules\User\Notifications\UserActivationNotification;
use Modules\Auth\Services\EmailVerificationOtpService;
use Exception;
class CreateNewUser implements CreatesNewUsers
@@ -54,38 +53,6 @@ class CreateNewUser implements CreatesNewUsers
$user->assignRole($role);
}
app(EmailVerificationOtpService::class)->send($user);
// Send notification to admins if user requires activation
if ($user->status === 'pending') {
$this->notifyAdminsForActivation($user);
}
return $user;
}
/**
* Notify admins about new user requiring activation
*/
private function notifyAdminsForActivation(User $newUser): void
{
try {
$adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT'];
// Get users with specific roles plus admins (PENTADBIR and DEVELOPER)
$adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles);
$sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth
foreach ($adminUsers as $admin) {
try {
$admin->notify(new UserActivationNotification($newUser, $sender));
} catch (Exception $e) {
Log::error('Failed to send user activation notification: '.$e->getMessage());
}
}
} catch (Exception $e) {
Log::error('Failed to notify admins for user activation: '.$e->getMessage());
}
}
}
@@ -3,39 +3,19 @@
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse;
use Modules\Auth\Services\AuthSessionService;
use Modules\Auth\Services\EmailVerificationOtpService;
class LoginResponse implements ContractsLoginResponse
{
public function __construct(
protected AuthSessionService $authSession,
protected EmailVerificationOtpService $otpService,
) {}
public function toResponse($request): JsonResponse
{
$user = $request->user();
if (! $user->hasVerifiedEmail()) {
$this->otpService->send($user);
Auth::guard(config('fortify.guard'))->logout();
return response()->json([
'success' => true,
'message' => 'Sila semak e-mel anda untuk kod pengesahan 6 digit.',
'data' => [
'email' => $user->email,
'requires_email_verification' => true,
],
]);
}
return $this->authSession->createAuthResponse(
$user,
$request->user(),
'Login successful'
);
}
@@ -3,24 +3,21 @@
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Modules\Auth\Services\AuthSessionService;
class RegisterResponse implements RegisterResponseContract
{
public function __construct(
protected AuthSessionService $authSession,
) {}
public function toResponse($request): JsonResponse
{
$email = $request->user()?->email;
Auth::guard(config('fortify.guard'))->logout();
return response()->json([
'success' => true,
'message' => 'Pendaftaran berjaya. Sila semak e-mel anda untuk kod pengesahan 6 digit.',
'data' => [
'email' => $email,
'requires_email_verification' => true,
],
], 201);
return $this->authSession->createAuthResponse(
$request->user(),
'Pendaftaran berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.',
201
);
}
}
@@ -1,38 +0,0 @@
<?php
namespace Modules\Auth\Emails;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailVerificationOtpEmail extends Notification
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected string $otp
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$minutes = (int) config('auth.email_verification.expiry_minutes', 10);
return $this->mailMessage(
subject: 'Pengesahan E-mel - Kod OTP',
view: 'auth::emails.verification-otp',
data: [
'name' => $notifiable->name,
'otp' => $this->otp,
'minutes' => $minutes,
],
);
}
}
@@ -1,44 +0,0 @@
<?php
namespace Modules\Auth\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailVerificationOtp extends Model
{
use HasUuids;
protected $table = 'email_verification_otps';
protected $fillable = [
'user_id',
'code',
'expires_at',
'attempts',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'attempts' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function hasExceededMaxAttempts(): bool
{
return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5);
}
}
+9 -11
View File
@@ -2,7 +2,8 @@
namespace Modules\Auth\Entities;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -22,12 +23,13 @@ use Modules\Role\Entities\Role;
use Modules\User\Entities\Address;
use Modules\User\Entities\BankDetail;
use Modules\User\Entities\Employment;
use Modules\Auth\Notifications\VerifyEmailNotification;
use Modules\User\Entities\Heir;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes;
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, MustVerifyEmailTrait, Notifiable, SoftDeletes;
protected $table = 'users';
@@ -301,9 +303,9 @@ class User extends Authenticatable
return $currentUser->hasPermissionTo('menyamar pengguna');
}
public function hasVerifiedEmail(): bool
public function sendEmailVerificationNotification(): void
{
return $this->email_verified_at !== null;
$this->notify(new VerifyEmailNotification);
}
/**
@@ -315,15 +317,11 @@ class User extends Authenticatable
}
/**
* Whether credentials are valid for issuing a session (includes verified pending users).
* Whether credentials are valid for issuing a session (includes pending users awaiting admin activation).
*/
public function canAuthenticate(): bool
{
if ($this->canLogin()) {
return true;
}
return $this->hasVerifiedEmail() && $this->status === 'pending';
return in_array($this->status, ['active', 'pending'], true);
}
/**
@@ -3,60 +3,66 @@
namespace Modules\Auth\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\AuthSessionService;
use Modules\Auth\Services\EmailVerificationOtpService;
class EmailVerificationController extends Controller
{
public function __construct(
protected EmailVerificationOtpService $otpService,
protected AuthSessionService $authSession
) {}
public function verify(Request $request): JsonResponse
public function verify(Request $request, string $id, string $hash): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
'otp' => ['required', 'string', 'digits:6'],
]);
$user = User::where('email', $validated['email'])->first();
if (! $user) {
return response()->json([
'success' => false,
'message' => 'Kod OTP tidak sah.',
], 422);
if (! URL::hasValidSignature($request)) {
return $this->redirectToFrontend('invalid');
}
$this->otpService->verify($user, $validated['otp']);
$user = User::find($id);
$user->refresh();
if (! $user || ! hash_equals($hash, sha1($user->getEmailForVerification()))) {
return $this->redirectToFrontend('invalid');
}
return $this->authSession->createAuthResponse(
$user,
'E-mel anda telah berjaya disahkan. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.'
);
if ($user->hasVerifiedEmail()) {
return $this->redirectToFrontend('already');
}
$user->markEmailAsVerified();
event(new Verified($user));
return $this->redirectToFrontend('success', $user);
}
public function resend(Request $request): JsonResponse
public function send(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
]);
$user = $request->user();
$user = User::where('email', $validated['email'])->first();
if ($user && ! $user->hasVerifiedEmail()) {
$this->otpService->send($user);
if ($user->hasVerifiedEmail()) {
return response()->json([
'success' => true,
'message' => 'E-mel anda telah disahkan.',
]);
}
$user->sendEmailVerificationNotification();
return response()->json([
'success' => true,
'message' => 'Jika e-mel wujud dan belum disahkan, kod OTP baharu telah dihantar.',
'message' => 'Pautan pengesahan e-mel telah dihantar.',
]);
}
protected function redirectToFrontend(string $status, ?User $user = null): RedirectResponse
{
$baseUrl = rtrim(config('user.frontend_url'), '/');
$path = config('auth.verification.frontend_redirect_path', '/profile');
if ($user?->status === 'pending') {
$path = '/register?registered=success';
}
return redirect()->away("{$baseUrl}{$path}?verified={$status}");
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Auth\Notifications;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\URL;
use Modules\Auth\Entities\User;
class VerifyEmailNotification extends Notification
{
use BuildsMailMessage;
use Queueable;
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$expireMinutes = (int) config('auth.verification.expire_minutes', 60);
return $this->mailMessage(
subject: 'Pengesahan E-mel - MyKOPKB',
view: 'auth::emails.verify-email',
data: [
'name' => $notifiable->name,
'verificationUrl' => $this->verificationUrl($notifiable),
'expireMinutes' => $expireMinutes,
],
);
}
protected function verificationUrl(User $notifiable): string
{
$expireMinutes = (int) config('auth.verification.expire_minutes', 60);
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes($expireMinutes),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
}
+7 -4
View File
@@ -11,10 +11,9 @@ use Laravel\Fortify\Http\Controllers\RegisteredUserController;
Route::post('/register', [RegisteredUserController::class, 'store']);
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
->middleware('throttle:email-verification');
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
->middleware('throttle:email-verification-resend');
Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])
->middleware('throttle:email-verification')
->name('verification.verify');
Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
->middleware('throttle:password-reset-request');
@@ -30,4 +29,8 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// logout
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']);
// optional email verification (post-login)
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
->middleware('throttle:email-verification-resend');
});
@@ -1,98 +0,0 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Entities\EmailVerificationOtp;
use Modules\Auth\Entities\User;
use Modules\Auth\Emails\EmailVerificationOtpEmail;
class EmailVerificationOtpService
{
public function send(User $user): void
{
if ($user->hasVerifiedEmail()) {
return;
}
$otp = $this->generateOtp();
EmailVerificationOtp::query()
->where('user_id', $user->id)
->delete();
EmailVerificationOtp::create([
'user_id' => $user->id,
'code' => Hash::make($otp),
'expires_at' => now()->addMinutes($this->expiryMinutes()),
'attempts' => 0,
]);
$user->notify(new EmailVerificationOtpEmail($otp));
}
public function verify(User $user, string $otp): void
{
if ($user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'email' => ['E-mel anda telah disahkan.'],
]);
}
$record = EmailVerificationOtp::query()
->where('user_id', $user->id)
->latest()
->first();
if (! $record) {
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'],
]);
}
if ($record->isExpired()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'],
]);
}
if ($record->hasExceededMaxAttempts()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'],
]);
}
if (! Hash::check($otp, $record->code)) {
$record->increment('attempts');
if ($record->fresh()->hasExceededMaxAttempts()) {
$record->delete();
}
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak sah.'],
]);
}
$user->forceFill(['email_verified_at' => now()])->save();
EmailVerificationOtp::query()
->where('user_id', $user->id)
->delete();
}
protected function generateOtp(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
protected function expiryMinutes(): int
{
return (int) config('auth.email_verification.expiry_minutes', 10);
}
}
@@ -1,21 +0,0 @@
@component('mail::message')
@include('emails.partials.header')
# Pengesahan E-mel
Assalamualaikum **{{ $name }}**,
Terima kasih kerana mendaftar. Gunakan kod OTP di bawah untuk mengesahkan alamat e-mel anda.
@component('mail::panel')
<div style="text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 6px;">
{{ $otp }}
</div>
@endcomponent
Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
Jika anda tidak membuat pendaftaran ini, abaikan e-mel ini.
@include('emails.partials.footer')
@endcomponent
@@ -0,0 +1,19 @@
@component('mail::message')
@include('emails.partials.header')
# Pengesahan E-mel
Assalamualaikum **{{ $name }}**,
Terima kasih kerana mendaftar dengan MyKOPKB. Sila sahkan alamat e-mel anda dengan mengklik butang di bawah.
@component('mail::button', ['url' => $verificationUrl])
Sahkan E-mel
@endcomponent
Pautan ini akan tamat tempoh dalam **{{ $expireMinutes }} minit**.
Jika anda tidak membuat permintaan ini, abaikan e-mel ini.
@include('emails.partials.footer')
@endcomponent
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\EmailVerificationOtpService;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Enums\ApplicationStatus;
use Modules\MembershipApplication\Enums\BoardDecision;
@@ -513,8 +512,6 @@ class MembershipApplicationService
]);
}
app(EmailVerificationOtpService::class)->send($user);
return [
'user' => $user,
'plainPassword' => $plainPassword,
+2 -6
View File
@@ -57,9 +57,9 @@ class FortifyServiceProvider extends ServiceProvider
});
RateLimiter::for('email-verification', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
$throttleKey = Str::transliterate($request->route('id', '').'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
return Limit::perMinute(6)->by($throttleKey);
});
RateLimiter::for('email-verification-resend', function (Request $request) {
@@ -87,10 +87,6 @@ class FortifyServiceProvider extends ServiceProvider
$shouldBypassPassword = config('app.env', 'local');
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
if (! $user->hasVerifiedEmail()) {
return $user;
}
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
+4 -4
View File
@@ -113,13 +113,13 @@ return [
/*
|--------------------------------------------------------------------------
| Email Verification OTP
| Email Verification Link
|--------------------------------------------------------------------------
*/
'email_verification' => [
'expiry_minutes' => (int) env('EMAIL_VERIFICATION_OTP_EXPIRY_MINUTES', 10),
'max_attempts' => (int) env('EMAIL_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
'verification' => [
'expire_minutes' => (int) env('EMAIL_VERIFICATION_EXPIRE_MINUTES', 60),
'frontend_redirect_path' => env('EMAIL_VERIFICATION_REDIRECT_PATH', '/profile'),
],
/*
@@ -6,10 +6,12 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('email_verification_otps');
}
public function down(): void
{
Schema::create('email_verification_otps', function (Blueprint $table) {
$table->uuid('id')->primary();
@@ -18,16 +20,6 @@ return new class extends Migration
$table->timestamp('expires_at');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('email_verification_otps');
}
};