Зміна електронної адреси здається звичайним оновленням профілю.
Користувач вводить нову адресу, натискає кнопку збереження, а сервер змінює поле email.
Але електронна адреса часто є не просто текстом у профілі. Вона може використовуватися для входу, відновлення доступу, повідомлень і підтвердження особи.
Тому для мене введення нової адреси ще не означає, що користувач справді контролює її.
Поки нова адреса не підтверджена, стара залишається основною.
Просте оновлення поля створює небезпечне вікно
$user->email = $request->email;$user->email_verified_at = null; $user->save(); $this->sendVerificationEmail( $user,);Після такого запису система вже вважає нову адресу адресою користувача.
Але володіння нею ще не доведене.
Стара адреса може перестати працювати для входу або відновлення доступу ще до того, як нова стала надійною точкою контакту.
Тобто система змінює важливу частину облікового запису раніше, ніж має достатньо доказів для цієї зміни.
Я зберігаю нову адресу як запит на зміну
Поточна адресакористувача old@example.com | | користувач просить зміну vзапит на зміну new@example.com | | підтвердження vнова основна адреса new@example.comЯ розділяю два стани.
Перший - підтверджена основна адреса облікового запису.
Другий - адреса, на яку користувач лише попросив перейти.
Поки друга адреса не підтверджена, вона не замінює першу і не повинна автоматично отримувати всі права основної адреси.
Запит на зміну має власний життєвий цикл
Schema::create( 'email_change_requests', function (Blueprint $table): void { $table->id(); $table->foreignId('user_id'); $table->string( 'proposed_email', ); $table->string( 'token_hash', ); $table->timestamp(Це лише спрощений приклад структури.
Для мене важлива сама ідея: очікувана зміна є окремим станом, а не наполовину зміненим користувачем.
Запит має власника, нову адресу, обмежений строк дії та інформацію про те, чи був він уже використаний.
Нову адресу я нормалізую до створення запиту
$email = mb_strtolower( trim( $request->string('email')->toString() ));Перевірка унікальності має працювати з тією ж формою адреси, яка використовується для збереження та порівняння.
Інакше одна частина системи може вважати дві адреси різними, а інша - однаковими.
Тому нормалізація для мене є частиною межі ще до створення запиту на зміну.
Першу перевірку унікальності я роблю одразу
if ( User::query() ->where( 'email', $email, ) ->whereKeyNot( $user->id, ) ->exists()) { throw ValidationException::withMessages([ 'email' => 'This email is already in use.', ]);}Ця перевірка корисна для швидкого зворотного зв’язку користувачу.
Якщо адреса вже зайнята, немає сенсу відправляти лист підтвердження.
Але ця перевірка ще не є остаточною гарантією.
Секрет підтвердження я не зберігаю у відкритому вигляді
$token = Str::random(64); $request = EmailChangeRequest::query() ->create([ 'user_id' => $user->id, 'proposed_email' => $email, 'token_hash' => hash( 'sha256', $token, ), Сирий токен потрібен лише для формування посилання підтвердження.
У базі я залишаю лише значення, з яким можна перевірити отриманий токен, але не відновити початковий секрет.
Інший варіант - підписане посилання з обмеженим строком дії. В обох випадках підтвердження має бути пов’язане з конкретним запитом і мати кінцевий строк життя.
Посилання на зміну не повинно працювати безстроково
Запит на зміну адреси відображає намір користувача в конкретний момент часу.
Я не хочу, щоб старе посилання через місяці несподівано змінило важливу частину облікового запису.
Тому кожен запит має expires_at, а прострочене підтвердження відхиляється.
Новий запит повинен робити старий неактуальним
Запит 1 old@example.com-> first-new@example.com Користувач передумав Запит 2 old@example.com-> second-new@example.com Запит 1Якщо користувач кілька разів вводить різні адреси, старі посилання не повинні залишатися рівноправними.
Новий запит замінює попередній або попередній явно позначається неактуальним.
Інакше користувач може підтвердити старий лист уже після того, як вибрав іншу адресу.
Підтвердження перевіряє стан ще раз
DB::transaction( function () use ( $requestId, $token, ): void { $change = EmailChangeRequest::query() ->whereKey($requestId) ->lockForUpdate() ->firstOrFail(); if ( $change->consumed_at !== null || $change->expires_at->isPast() ) { throw InvalidEmailChange::requestUnavailable(); }У момент підтвердження я вже не покладаюся на те, що було правильним під час створення запиту.
Перевіряється сам запит: чи він ще діє, чи не був уже використаний, чи збігається підтвердження.
А потім повторно перевіряються умови, які могли змінитися за час очікування.
Унікальність потрібно перевірити ще раз саме під час підтвердження
10:00 Користувач A просить:new@example.com Адреса вільна 10:05 Інший обліковий записотримує:new@example.com 10:10Це один із найважливіших моментів у всьому сценарії.
Перевірка унікальності перед відправленням листа лише доводить, що адреса була вільна тоді.
Між створенням запиту і переходом за посиланням можуть пройти хвилини або години.
Тому перед фактичною зміною основної адреси я перевіряю унікальність ще раз.
Остаточну гарантію унікальності я залишаю базі даних
Навіть повторна перевірка в коді не закриває всі конкурентні сценарії.
Два запити можуть одночасно перевірити одну й ту саму адресу до того, як один із них виконає запис.
Тому унікальне обмеження в базі даних залишається останньою гарантією.
Код дає зрозумілу поведінку і хорошу помилку. База гарантує, що суперечливий стан фізично не буде збережений.
Саму зміну основної адреси я виконую атомарно
$user = User::query() ->whereKey( $change->user_id, ) ->lockForUpdate() ->firstOrFail(); $emailIsTaken = User::query() ->where( 'email', $change->proposed_email, ) ->whereKeyNot( $user->id, ) ->exists();Після успішного підтвердження нова адреса вже доведена як контрольована користувачем.
Тому вона може стати основною і відразу вважатися підтвердженою.
У тій самій транзакції запит позначається використаним, щоб повторне відкриття того самого посилання не виконувало операцію вдруге.
До підтвердження стара адреса продовжує працювати
Це важлива властивість такого підходу.
Користувач не втрачає основну адресу лише через помилку під час введення нової.
Якщо лист не прийшов, адреса введена неправильно або користувач просто передумав, поточний обліковий запис залишається в нормальному стані.
Невдалий процес зміни не перетворюється на проблему доступу.
Повторне відкриття посилання не повинно повторювати зміну
Підтвердження має бути одноразовою операцією.
Після успішного використання запит переходить у завершений стан.
Наступний перехід за тим самим посиланням не повинен знову змінювати адресу, надсилати ті самі побічні дії або відновлювати старий запит.
Поточний сеанс не повинен випадково ламатися під час зміни
Зміна електронної адреси не повинна випадково залишити поточний сеанс у суперечливому стані.
Після успішного підтвердження дані автентифікованого користувача мають відповідати вже новій основній адресі.
Конкретна політика повторного входу залежить від вимог продукту, але вона повинна бути явною, а не випадковим наслідком зміни одного поля.
Підтвердження доводить контроль нової адреси, а не довіру до старого стану
Мені важливо не змішувати різні докази.
Те, що користувач уже увійшов у свій обліковий запис, доводить його доступ до поточного сеансу.
Але це не доводить володіння новою електронною адресою.
Посилання, доставлене на нову адресу, якраз і закриває цю окрему перевірку.
Повний потік зміни адреси
1. Користувач вводить нову адресу 2. Адреса нормалізується 3. Виконується рання перевірка унікальності 4. Основна адреса НЕ змінюється 5. Створюється запит на зміну зі строком дії 6. На нову адресу надсилається підтвердження Що я перевіряю тестами
1. Користувач вводить нову адресу -> основна адреса не змінюється -> створюється запит на зміну 2. Правильне підтвердження -> нова адреса стає основною -> адреса підтверджена -> запит використаний 3. Прострочене підтвердження -> адреса не змінюється 4. Неправильний токенЩо я перевіряю в механізмі зміни електронної адреси
Мій короткий список:
- Чи залишається поточна адреса основною до підтвердження?
- Чи зберігається нова адреса окремо як очікувана зміна?
- Чи має запит обмежений строк дії?
- Чи не зберігається секрет підтвердження у відкритому вигляді?
- Чи робить новий запит попередній неактуальним?
- Чи перевіряється унікальність і під час створення, і під час підтвердження?
- Чи є унікальне обмеження в базі даних?
- Чи виконується остаточна зміна атомарно?
- Чи можна використати підтвердження лише один раз?
- Чи не втрачає користувач доступ, якщо нову адресу так і не підтвердив?
Збереження форми і зміна облікового запису - це не одна подія
Для звичайного профільного поля натискання кнопки збереження часто справді означає негайну зміну.
Для електронної адреси цього недостатньо.
Спочатку користувач лише заявляє про намір перейти на нову адресу.
Система зберігає цей намір окремо, надсилає підтвердження, обмежує строк його дії і не чіпає поточну основну адресу.
Лише після підтвердження вона повторно перевіряє актуальний стан і атомарно завершує зміну.
Я не змінюю важливий ідентифікатор облікового запису раніше, ніж система отримала доказ контролю нової адреси.
Нова електронна адреса стає основною не після збереження форми, а після доказу, що користувач справді має до неї доступ.



