From 99d24c38c7b9c5a8cd00cccc83155832fb0f1c5f Mon Sep 17 00:00:00 2001 From: faezehzafarbakhsh Date: Mon, 1 Jun 2026 13:43:16 +0330 Subject: [PATCH] create forgote password --- .../ForgotPasswordOtpController.php | 89 +++++++++++++++ backend/app/Models/PasswordResetOtp.php | 10 ++ .../ResetPasswordOtpNotification.php | 32 ++++++ .../Services/Auth/PasswordResetOtpService.php | 103 ++++++++++++++++++ .../ForgotPasswordOtpController.php | 89 +++++++++++++++ ...62350_create_password_reset_otps_table.php | 30 +++++ 6 files changed, 353 insertions(+) create mode 100644 backend/app/Admin/Controllers/ForgotPasswordOtpController.php create mode 100644 backend/app/Models/PasswordResetOtp.php create mode 100644 backend/app/Notifications/ResetPasswordOtpNotification.php create mode 100644 backend/app/Services/Auth/PasswordResetOtpService.php create mode 100644 backend/app/User/Controllers/ForgotPasswordOtpController.php create mode 100755 backend/database/migrations/2026_06_01_062350_create_password_reset_otps_table.php diff --git a/backend/app/Admin/Controllers/ForgotPasswordOtpController.php b/backend/app/Admin/Controllers/ForgotPasswordOtpController.php new file mode 100644 index 00000000..cdd62a45 --- /dev/null +++ b/backend/app/Admin/Controllers/ForgotPasswordOtpController.php @@ -0,0 +1,89 @@ +validate([ + 'email' => ['required', 'email', 'exists:users,email'], + ]); + + $email = $validated['email']; + + $key = 'user-forgot-password-otp:' . $email . ':' . $request->ip(); + + if (RateLimiter::tooManyAttempts($key, 3)) { + throw ValidationException::withMessages([ + 'email' => ['Too many OTP requests. Please try again later.'], + ]); + } + + RateLimiter::hit($key, 600); + + $user = User::query()->where('email', $email)->firstOrFail(); + + $this->otpService->sendOtp($user, 'user'); + + return response()->json([ + 'message' => 'OTP sent to your email.', + ]); + } + + public function verifyOtp(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'otp' => ['required', 'digits:6'], + ]); + + $this->otpService->verifyOtp( + email: $validated['email'], + otp: $validated['otp'], + guard: 'user' + ); + + return response()->json([ + 'message' => 'OTP verified successfully.', + ]); + } + + public function resetPassword(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'password' => ['required', 'confirmed',], + ]); + + $record = $this->otpService->ensureVerified( + email: $validated['email'], + guard: 'user' + ); + + $user = User::query()->where('email', $validated['email'])->firstOrFail(); + + $user->update([ + 'password' => Hash::make($validated['password']), + ]); + + $record->delete(); + + return response()->json([ + 'message' => 'User password reset successfully.', + ]); + } +} diff --git a/backend/app/Models/PasswordResetOtp.php b/backend/app/Models/PasswordResetOtp.php new file mode 100644 index 00000000..0be42dab --- /dev/null +++ b/backend/app/Models/PasswordResetOtp.php @@ -0,0 +1,10 @@ +subject('Reset Password OTP') + ->greeting('Hello!') + ->line('Your password reset code is:') + ->line($this->otp) + ->line('This code will expire in 10 minutes.') + ->line('If you did not request password reset, ignore this email.'); + } +} diff --git a/backend/app/Services/Auth/PasswordResetOtpService.php b/backend/app/Services/Auth/PasswordResetOtpService.php new file mode 100644 index 00000000..aa73ecdd --- /dev/null +++ b/backend/app/Services/Auth/PasswordResetOtpService.php @@ -0,0 +1,103 @@ +where('email', $user->email) + ->where('guard', $guard) + ->delete(); + + PasswordResetOtp::query()->create([ + 'email' => $user->email, + 'guard' => $guard, + 'otp' => Hash::make($otp), + 'expires_at' => now()->addMinutes(10), + ]); + + $user->notify(new ResetPasswordOtpNotification($otp)); + } + + public function verifyOtp(string $email, string $otp, string $guard = 'user'): void + { + $record = PasswordResetOtp::query()->where('email', $email) + ->where('guard', $guard) + ->latest() + ->first(); + + if (! $record) { + throw ValidationException::withMessages([ + 'otp' => ['Invalid OTP.'], + ]); + } + + if ($record->isExpired()) { + $record->delete(); + + throw ValidationException::withMessages([ + 'otp' => ['OTP has expired.'], + ]); + } + + if ($record->attempts >= 5) { + $record->delete(); + + throw ValidationException::withMessages([ + 'otp' => ['Too many wrong attempts. Please request a new OTP.'], + ]); + } + + if (! Hash::check($otp, $record->otp)) { + $record->increment('attempts'); + + throw ValidationException::withMessages([ + 'otp' => ['Invalid OTP.'], + ]); + } + + $record->update([ + 'verified_at' => now(), + ]); + } + + public function ensureVerified(string $email, string $guard = 'user'): PasswordResetOtp + { + $record = PasswordResetOtp::query()->where('email', $email) + ->where('guard', $guard) + ->latest() + ->first(); + + if (! $record || ! $record->isVerified()) { + throw ValidationException::withMessages([ + 'otp' => ['Please verify OTP first.'], + ]); + } + + if ($record->isExpired()) { + $record->delete(); + + throw ValidationException::withMessages([ + 'otp' => ['OTP has expired.'], + ]); + } + + return $record; + } + + public function deleteOtp(string $email, string $guard = 'user'): void + { + PasswordResetOtp::query()->where('email', $email) + ->where('guard', $guard) + ->delete(); + } +} diff --git a/backend/app/User/Controllers/ForgotPasswordOtpController.php b/backend/app/User/Controllers/ForgotPasswordOtpController.php new file mode 100644 index 00000000..51e3889b --- /dev/null +++ b/backend/app/User/Controllers/ForgotPasswordOtpController.php @@ -0,0 +1,89 @@ +validate([ + 'email' => ['required', 'email', 'exists:experts,email'], + ]); + + $email = $validated['email']; + + $key = 'expert-forgot-password-otp:' . $email . ':' . $request->ip(); + + if (RateLimiter::tooManyAttempts($key, 3)) { + throw ValidationException::withMessages([ + 'email' => ['Too many OTP requests. Please try again later.'], + ]); + } + + RateLimiter::hit($key, 600); + + $expert = Expert::query()->where('email', $email)->firstOrFail(); + + $this->otpService->sendOtp($expert, 'expert'); + + return response()->json([ + 'message' => 'OTP sent to your email.', + ]); + } + + public function verifyOtp(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:experts,email'], + 'otp' => ['required', 'digits:6'], + ]); + + $this->otpService->verifyOtp( + email: $validated['email'], + otp: $validated['otp'], + guard: 'expert' + ); + + return response()->json([ + 'message' => 'OTP verified successfully.', + ]); + } + + public function resetPassword(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:experts,email'], + 'password' => ['required', 'confirmed'], + ]); + + $record = $this->otpService->ensureVerified( + email: $validated['email'], + guard: 'expert' + ); + + $expert = Expert::query()->where('email', $validated['email'])->firstOrFail(); + + $expert->update([ + 'password' => Hash::make($validated['password']), + ]); + + $record->delete(); + + return response()->json([ + 'message' => 'Expert password reset successfully.', + ]); + } +} diff --git a/backend/database/migrations/2026_06_01_062350_create_password_reset_otps_table.php b/backend/database/migrations/2026_06_01_062350_create_password_reset_otps_table.php new file mode 100755 index 00000000..2b229b8e --- /dev/null +++ b/backend/database/migrations/2026_06_01_062350_create_password_reset_otps_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('email')->index(); + $table->string('otp'); + $table->string('guard_name'); + $table->timestamp('expires_at'); + $table->timestamp('verified_at')->nullable(); + $table->unsignedTinyInteger('attempts')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_reset_otps'); + } +};