generated from boboko/starter
Feat: Moving Email Updates to Core
This commit is contained in:
@@ -3,43 +3,36 @@
|
|||||||
namespace App\Http\Controllers\Account;
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Mail\EmailChangeCodeMail;
|
|
||||||
use App\Mail\EmailChangedNoticeMail;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
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\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\View\View;
|
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 →
|
* Changing the login email — a thin wrapper over boboko-core's
|
||||||
* switched. The email is the login, so it only changes once the shopper has
|
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||||
* proved they can receive mail there; a typo can never lock them out.
|
* request/confirm mechanics, throttling, pending-change storage, and
|
||||||
*
|
* mailables. This controller's own job is just the storefront's session-
|
||||||
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
|
* 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
|
||||||
* Core has no email-change flow, so the pending change lives in the session
|
* translating the service's exceptions into the flash-message flow the
|
||||||
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
|
* views expect.
|
||||||
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
|
|
||||||
*/
|
*/
|
||||||
class EmailController extends Controller
|
class EmailController extends Controller
|
||||||
{
|
{
|
||||||
private const SESSION_KEY = 'email_change';
|
private const SESSION_KEY = 'pending_email_change';
|
||||||
private const EXPIRY_MINUTES = 10;
|
|
||||||
private const MAX_ATTEMPTS = 5;
|
|
||||||
private const SEND_LIMIT = 3;
|
|
||||||
private const SEND_DECAY_SECONDS = 600;
|
|
||||||
|
|
||||||
public function edit(string $locale, Request $request): View
|
public function edit(string $locale, Request $request): View
|
||||||
{
|
{
|
||||||
return view('account.email', ['user' => $request->user()]);
|
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();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse
|
|||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::notIn([$user->email]),
|
Rule::notIn([$user->email]),
|
||||||
Rule::unique($user->getTable(), 'email')->ignore($user->id),
|
|
||||||
],
|
],
|
||||||
], [
|
], [
|
||||||
'email.not_in' => __('storefront.account.email_same'),
|
'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')]);
|
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||||
|
|
||||||
return redirect()->route('account.email.code');
|
return redirect()->route('account.email.code');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function code(string $locale, Request $request): View|RedirectResponse
|
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 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');
|
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()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->with('status', __('storefront.auth.code_resent'));
|
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');
|
return redirect()->route('account.email.edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||||
|
|
||||||
$valid = $pending['code_hash'] !== null
|
try {
|
||||||
&& now()->timestamp < $pending['expires_at']
|
$emailChange->confirm($request->user(), $validated['code']);
|
||||||
&& Hash::check($validated['code'], $pending['code_hash']);
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
|
||||||
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()) {
|
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
return redirect()->route('account.email.edit')
|
return redirect()->route('account.email.edit')
|
||||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
->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);
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The code that confirms a new login email — see Account\EmailController.
|
|
||||||
*/
|
|
||||||
class EmailChangeCodeMail extends Mailable
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $code,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(view: 'emails.email-change-code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sent to the OLD address once the login email has changed (see
|
|
||||||
* Account\EmailController), so the owner notices a takeover. The new address
|
|
||||||
* is shown masked, e.g. "n•••@example.com".
|
|
||||||
*/
|
|
||||||
class EmailChangedNoticeMail extends Mailable
|
|
||||||
{
|
|
||||||
public readonly string $maskedEmail;
|
|
||||||
|
|
||||||
public function __construct(string $newEmail)
|
|
||||||
{
|
|
||||||
[$local, $domain] = explode('@', $newEmail, 2);
|
|
||||||
|
|
||||||
$this->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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user