diff --git a/app/Http/Controllers/Account/EmailController.php b/app/Http/Controllers/Account/EmailController.php index 253083e..df860e8 100644 --- a/app/Http/Controllers/Account/EmailController.php +++ b/app/Http/Controllers/Account/EmailController.php @@ -3,43 +3,36 @@ namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; -use App\Mail\EmailChangeCodeMail; -use App\Mail\EmailChangedNoticeMail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\View\View; -use Modules\Core\Customer\Services\GuestOrderClaimer; +use Modules\Core\Auth\Exceptions\OtpThrottledException; +use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException; +use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException; +use Modules\Core\Customer\Services\CustomerEmailChangeService; /** - * Changing the login email: new email → 6-digit code sent to that address → - * switched. The email is the login, so it only changes once the shopper has - * proved they can receive mail there; a typo can never lock them out. - * - * Once switched, the OLD address gets a notice (EmailChangedNoticeMail). - * - * Core has no email-change flow, so the pending change lives in the session - * (the new address, a hash of the code, expiry and wrong-guess count). Limits - * mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code. + * Changing the login email — a thin wrapper over boboko-core's + * Customer\Services\CustomerEmailChangeService, which owns the actual + * request/confirm mechanics, throttling, pending-change storage, and + * mailables. This controller's own job is just the storefront's session- + * scoped "which email did I just ask to switch to" UI state (so the + * .code/.resend pages know which address to show/resend to) and + * translating the service's exceptions into the flash-message flow the + * views expect. */ class EmailController extends Controller { - private const SESSION_KEY = 'email_change'; - private const EXPIRY_MINUTES = 10; - private const MAX_ATTEMPTS = 5; - private const SEND_LIMIT = 3; - private const SEND_DECAY_SECONDS = 600; + private const SESSION_KEY = 'pending_email_change'; public function edit(string $locale, Request $request): View { return view('account.email', ['user' => $request->user()]); } - public function send(string $locale, Request $request): RedirectResponse + public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse { $user = $request->user(); @@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse 'email', 'max:255', Rule::notIn([$user->email]), - Rule::unique($user->getTable(), 'email')->ignore($user->id), ], ], [ 'email.not_in' => __('storefront.account.email_same'), - 'email.unique' => __('storefront.account.email_taken'), ]); - if (! $this->sendCode($request, $validated['email'])) { + try { + $emailChange->request($user, $validated['email']); + } catch (EmailAlreadyTakenException) { + return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (OtpThrottledException) { return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]); } + $request->session()->put(self::SESSION_KEY, $validated['email']); + return redirect()->route('account.email.code'); } public function code(string $locale, Request $request): View|RedirectResponse { - $pending = $request->session()->get(self::SESSION_KEY); + $pendingEmail = $request->session()->get(self::SESSION_KEY); - if (! $pending) { + if (! $pendingEmail) { return redirect()->route('account.email.edit'); } - return view('account.email-code', ['email' => $pending['email']]); + return view('account.email-code', ['email' => $pendingEmail]); } - public function resend(string $locale, Request $request): RedirectResponse + public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse { - $pending = $request->session()->get(self::SESSION_KEY); + $pendingEmail = $request->session()->get(self::SESSION_KEY); - if (! $pending) { + if (! $pendingEmail) { return redirect()->route('account.email.edit'); } - if (! $this->sendCode($request, $pending['email'])) { + try { + $emailChange->request($request->user(), $pendingEmail); + } catch (EmailAlreadyTakenException) { + $request->session()->forget(self::SESSION_KEY); + + return redirect()->route('account.email.edit') + ->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (OtpThrottledException) { return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]); } return back()->with('status', __('storefront.auth.code_resent')); } - public function verify(string $locale, Request $request, GuestOrderClaimer $claimer): RedirectResponse + public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse { - $pending = $request->session()->get(self::SESSION_KEY); + $pendingEmail = $request->session()->get(self::SESSION_KEY); - if (! $pending) { + if (! $pendingEmail) { return redirect()->route('account.email.edit'); } $validated = $request->validate(['code' => ['required', 'digits:6']]); - $valid = $pending['code_hash'] !== null - && now()->timestamp < $pending['expires_at'] - && Hash::check($validated['code'], $pending['code_hash']); - - if (! $valid) { - // Too many wrong guesses burns the code; only "resend" helps then. - $pending['attempts']++; - - if ($pending['attempts'] >= self::MAX_ATTEMPTS) { - $pending['code_hash'] = null; - } - - $request->session()->put(self::SESSION_KEY, $pending); - - return back()->withErrors(['code' => __('storefront.auth.invalid_code')]); - } - - $user = $request->user(); - - // Someone may have signed up with this address since the code was sent. - if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) { + try { + $emailChange->confirm($request->user(), $validated['code']); + } catch (EmailAlreadyTakenException) { $request->session()->forget(self::SESSION_KEY); return redirect()->route('account.email.edit') ->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (InvalidEmailChangeCodeException) { + return back()->withErrors(['code' => __('storefront.auth.invalid_code')]); } - $oldEmail = $user->email; - - $user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save(); - - // Lets the owner notice if someone else changed it from a hijacked session. - Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email'])); - - // The code just proved they own the new address too. - $claimer->claim($user); - $request->session()->forget(self::SESSION_KEY); return redirect()->route('account')->with('status', __('storefront.account.email_changed')); } - - private function sendCode(Request $request, string $email): bool - { - $limiterKey = 'email-change:'.$request->user()->id; - - if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) { - return false; - } - - RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS); - - $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); - - $request->session()->put(self::SESSION_KEY, [ - 'email' => $email, - 'code_hash' => Hash::make($code), - 'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp, - 'attempts' => 0, - ]); - - Mail::to($email)->send(new EmailChangeCodeMail($code)); - - return true; - } } diff --git a/app/Mail/EmailChangeCodeMail.php b/app/Mail/EmailChangeCodeMail.php deleted file mode 100644 index 7a0af63..0000000 --- a/app/Mail/EmailChangeCodeMail.php +++ /dev/null @@ -1,27 +0,0 @@ -maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain; - } - - public function envelope(): Envelope - { - return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε'); - } - - public function content(): Content - { - return new Content(view: 'emails.email-changed-notice'); - } -} diff --git a/resources/views/emails/email-change-code.blade.php b/resources/views/vendor/core/auth/mail/email-change-code.blade.php similarity index 100% rename from resources/views/emails/email-change-code.blade.php rename to resources/views/vendor/core/auth/mail/email-change-code.blade.php diff --git a/resources/views/emails/email-changed-notice.blade.php b/resources/views/vendor/core/auth/mail/email-changed-notice.blade.php similarity index 100% rename from resources/views/emails/email-changed-notice.blade.php rename to resources/views/vendor/core/auth/mail/email-changed-notice.blade.php