Как я внедрил беспарольную авторизацию (Magic Links)
Как я внедрил беспарольную авторизацию (Magic Links) на связке Symfony + Next.js.
В разработке образовательных платформ и тренажеров есть одно золотое правило: каждое поле ввода режет конверсию. Поняв это, я на моем проекте rtn-tests.ru (платформа для подготовки к аттестации в Ростехнадзора) решил сделать ставку на стратегию Product-Led Growth (PLG).
Идея проста: пользователь приходит из поиска по SEO-запросу (например, ищет ответ на конкретный вопрос), видит правильный ответ и кнопку «Решать билет целиком». Он кликает, и мы пускаем его в тренажер абсолютно бесплатно и без регистрации, используя гостевые сессии (X-Guest-ID).
Но когда бесплатный лимит (например, 2 билета) исчерпан, нам нужно конвертировать гостя в пользователя, а потом в подписчика. И здесь классическая форма «Введите email, придумайте пароль, повторите пароль» работает как бетонная стена.
Решение очевидно Magic Links (Беспарольная авторизация). Вводишь email, кликаешь по ссылке в письме — и ты внутри. В этой статье я расскажу, как я реализовал этот флоу в раздельной архитектуре (Symfony API + Next.js App Router) и почему мне пришлось обходить стандартные механизмы league/oauth2-server.
Проблема: OAuth2 и отсутствие пароля Мой бэкенд построен на Symfony с использованием строгих принципов DDD и CQRS. За выдачу токенов отвечает библиотека league/oauth2-server-bundle.
Стандартный флоу этой библиотеки подразумевает использование строгих Grant Types. Если вы логинитесь через API, вы используете password grant (передаете логин и пароль) или authorization_code.
Но в случае с Magic Link пароля у нас нет!
По-хорошему, стандарт диктует написание собственного Custom Grant Type (например, MagicLinkGrant). Однако в реалиях стартапа писать десятки классов инфраструктурного кода для внедрения кастомного типа авторизации в ядро библиотеки - это оверинжиниринг. Нам нужно было прагматичное решение: сгенерировать валидные JWT и Refresh токены вручную, опираясь на внутренние механизмы библиотеки, но в обход ее «парадного входа».
Решение на Бэкенде (Symfony) Когда пользователь вводит email, мы генерируем одноразовый UUID-токен, сохраняем его в сущность User (со сроком жизни 15 минут) и отправляем письмо со ссылкой вида [https://rtn-tests.ru/auth/magic?token=](https://rtn-tests.ru/auth/magic?token=)...
Самая магия происходит в обработчике команды подтверждения (ConfirmMagicLinkHandler), когда фронтенд дергает API с этим токеном. Нам нужно выдать полноценные access_token и refresh_token, чтобы сессия жила долго.
Для Access Token создаем «голую» сущность и вручную подгружаем наш приватный RSA-ключ (CryptKey), которым библиотека подпишет JWT.
Но выдавать только access_token на 1 час - это издевательство над UX. Нужен refresh_token. В библиотеке OAuth2 он представляет собой зашифрованную строку. Для этого мы берем ключ шифрования OAUTH2_ENCRYPTION_KEY и используем Defuse\Crypto\Crypto.
Возвращаем токены на фронтенд. Стандартный эндпоинт рефреша в будущем без проблем расшифрует этот refresh_token и выдаст новую пару.
Важный архитектурный нюанс: В этот же момент мы диспатчим доменное событие GuestJoined. Все попытки тестирования (attempts), которые гость успел накликать анонимно, навсегда привязываются к его новому аккаунту.
Решение на Фронтенде (Next.js) На стороне Next.js я реализовал страницу-перехватчик. Пользователь переходит из почты, React-компонент вытаскивает токен из URL и отправляет его в Server Action.
Использование Server Actions в Next.js позволяет нам максимально безопасно работать с токенами. Браузер получает JWT, и мы тут же прячем их в HttpOnly куки:
Итоги Отказ от паролей при конверсии «гость -> пользователь» сработал идеально. Пользователь не ломает голову над сложным паролем, а мы получаем подтвержденный email уже на этапе создания аккаунта.
Ручная генерация токенов для league/oauth2-server оказалась прагматичным решением. Она не нарушает стандарты безопасности (токены подписаны и зашифрованы теми же ключами), экономит дни разработки, избежав написания кастомных Grant Type.