SmartFlow: «В начале был пароль...» или новая аутентификация VK ID

530670583152946480ffec096cbc55ed.jpg

Привет, Хабр! Исторически сложилось, что первым способом аутентификации (в 1960-х) с появлением доступных компьютеров стал пароль. О рисках его использования и об изобретённых человечеством альтернативах мы подробно рассказали в статье о будущем беспарольной аутентификации. Этот подход к проверке подлинности пользователя мы начали развивать с апреля 2022 года и развиваем по сей день.

Меня зовут Саша, я работаю Android-разработчиком в команде VK ID. Мой рассказ — про SmartFlow, новый процесс аутентификации ВКонтакте, нюансы его внедрения и отличия от старого процесса переключения факторов. Разумеется, рассказывать буду применительно к Android-платформе. 

ВКратце об эволюции входа ВКонтакте

Первая версия аутентификации и авторизации ВКонтакте работала через ввод логина и пароля. Но, как уже рассказывал мой коллега, такой способ было решено дополнить более прогрессивным решением. Тогда для защиты пользователей ВКонтакте появилась многофакторная аутентификация (MFA). Поскольку для подтверждения, как правило, достаточно двух факторов, чаще её называют двухфакторной аутентификацией (2FA). Первый фактор — это фактор знания, пароль. Второй — это фактор владения, для подтверждения которого запрашивалось и запрашивается SMS (или звонок-сброс). 

С прицелом на отказ от парольного входа мы внедрили в 2022 году беспарольную аутентификацию. Но уже не просто как часть входа ВКонтакте, а как часть единого аккаунта VK ID.

Беспарольная аутентификация 1.0, или Старый способ переключения факторов

Название этого способа аутентификации говорит само за себя: можно войти без ввода пароля. 

Идея 2FA была описана выше: чтобы подтвердить свою личность полноценно, требуется подтвердить как фактор знания, так и владения. Эта дополнительная защита, которая может уберечь аккаунт VK ID от взлома, опциональна. Идея же беспарольной аутентификации относится к 1FA-пользователям и позволяет войти по номеру телефона вместо того, чтобы вводить пароль. Сейчас мы уже привыкли к тому, что различные сервисы присылают нам после ввода номера SMS, но раньше практика была иной. При входе ВКонтакте запрашивал именно пароль. Не зная его, без восстановления доступа нельзя было попасть в свой аккаунт. До сих пор можно встретить ресурсы с подобной формой входа.

В апреле 2022 года внедрили беспарольную аутентификацию с помощью фактора владения. Сначала к фактору относились только SMS и звонок-сброс, позже добавили генерацию одноразовых кодов (с помощью приложений для генерации OTP-кодов, например Google-аутентификатора). В конце 2022 появилось подтверждение по Push-уведомлению и почте. Теперь у пользователей появилось несколько вариантов того, как обойти необходимость вспомнить и искать свой пароль.

Экран подтверждения беспарольной аутентификации 1.0. Android.

Экран подтверждения беспарольной аутентификации 1.0. Android.

Первый способ предлагался на усмотрение сервера. По нажатию «Подтвердить другим способом» на сервер уходил запрос в метод верификации, который уже решал, какой способ подтверждения будет следующим на основе таких аспектов, как конверсия и затраты (так, например, SMS очень дорого обходятся при многомиллионной аудитории, а цена Push-уведомлений не зависит от её размеров). Но, как дополнительная опция, сохранилась возможность войти и при помощи пароля.

В итоге у пользователей появились возможности:

1) Автоввод кода — по достижении нужного количества цифр код отправлялся сам, без нужды нажимать «Продолжить». 

2) Подстановка кода из SMS «на лету» благодаря внутренним механизмам системы (SMS Retriever).

3) Подстановка кода из буфера обмена, если вы подтверждаете по SMS. Свернули приложение и скопировали код (или даже всё сообщение), он автоматически подставится в поле и уйдёт на сервер.

4) Введение резервного кода. Если нет возможности посмотреть SMS, то можно ввести резервный код в то же самое поле.

Беспарольная аутентификация 2.0, или SmartFlow

SmartFlow — это новый виток развития двухфакторной аутентификации VK ID, который призван дать больше свободы и улучшить пользовательский опыт. Разберём, какие преимущества он даёт пользователю, а также бизнесу.

Ключевые преимущества для пользователя:

1) Самостоятельный выбор способа подтверждения из расширенного списка. У пользователя теперь появляется выбор, каким именно образом доказать свой фактор владения. В сравнении с предыдущей реализацией ему не нужно ожидать, пока система напомнит, какие способы у него есть — он сразу видит их все.

Работает это так: при нажатии кнопки «Подтвердить другим способом» (например, при входе 1FA-пользователем или при проверке фактора владения для 2FA) открывается нижняя шторка (в Android реализована через BottomSheetDialogFragment), в которой перечислены все доступные для данного аккаунта способы его подтверждения. Ими могут быть:

  • приложение для генерации кодов (пример: Google-аутентификатор);

  • уведомление на устройство — отправка push-уведомления на привязанное к аккаунту устройство;

  • номер телефона — отправка SMS или звонка-сброса;

  • электронная почта, привязанная к аккаунту;

  • пароль;

  • резервный код (выдаётся при подключении 2FA);

  • и OnePass — о нём кратко расскажу ниже.

Экран с выбором способа подтверждения, в простонародье «шторка». Android.

Экран с выбором способа подтверждения, в простонародье «шторка». Android.

На случай, если потерян доступ ко всем из предложенных факторов, в самом низу есть кнопка «Восстановить доступ к аккаунту». Она отправит вас в Restore и предоставит дополнительные варианты входа в аккаунт. 

2) Смена порядка фактора знания и фактора владения для 2FA-пользователей. Ранее при подключении 2FA первым шагом всегда запрашивали пароль. Для дополнительной защиты паролей мы решили сделать «перевёртыш»: теперь в начале запрашиваем фактор владения, а уже после — фактор знания. Таким образом, усложняется возможность брутфорса.

Экран подтверждения беспарольной аутентификации 1.0 SmartFlow. Android.

Экран подтверждения беспарольной аутентификации 1.0 SmartFlow. Android.

Ключевые преимущества для бизнеса

Конечно, эффективность тех или иных фич оценивают по бизнес-метрикам. Спустя некоторое время после запуска мы провели замеры, и вот что получилось:

1) Конверсия в авторизацию ВКонтакте составила +12 процентных пункта, что говорит о значимом приросте. Конверсия возросла благодаря показу пользователю всех возможных методов подтверждения, из которых он выбирает для себя наиболее удобный.

2) При этом поток в восстановление доступа  из авторизации уменьшился на 17%, а значит мы реже ведём пользователей на восстановление аккаунта. Как сказано выше, благодаря широкому ассортименту методов пользователь стал реже нажимать кнопку «Забыли пароль?».

3) Затраты же на платные валидации номера телефона сократились на 11% благодаря бесплатным способам: push«ам, коду на почту, приложениям для генерации кодов и резервным кодам (ранее было неочевидно, куда их вводить).

Можно сделать вывод, что у пользователей куда лучше стало получаться входить в аккаунты, а бизнес получил статистически значимый бонус в виде сокращения расходов на проверку номера.

Реализация. Что происходит, если выбранный способ недоступен?

Дальше речь пойдёт о реализации и тех её моментах, про которые хочется рассказать.

При разработке мы обнаружили следующую ситуацию. Допустим, пользователь выбрал в качестве способа подтверждения push-уведомление. Мы просим бэкенд отправить его, а тот обращается к специальному push-сервису, который пытается отправить уведомление пользователю. Но может возникнуть ситуация, когда отправить не получилось (например, возникла проблема с сетью), и узнаём мы об этом только гораздо позже. Что делать в такой ситуации?

Мы выделили подобные случаи в отдельные коды ошибок, которые обрабатываются на клиентах по следующему принципу:

Диаграмма деятельности выбора доступного метода.

Диаграмма деятельности выбора доступного метода.

Для сравнения текущего момента времени и таймаута метода использован SystemClock.elapsedRealtime().Причина в том, что это время с момента запуска устройства, которое продолжает считаться даже в режиме энергосбережения CPU. 

При отсутствии подходящих способов можно заметить всплывающее уведомление «Нет доступных методов подтверждения», который, как и в случае со шторкой, направит в Restore.

Кэширование доступных способов аутентификации

Для того, чтобы снизить нагрузку на бэкенд и потенциально снизить ошибки о флуде (возникают при частых обращениях к API-методам), в описанную выше каскадную модель мы добавили кэширование.

Как оно работает:

1) Полученный первоначальный список методов для подтверждения сохраняется в кэш (например, при первом нажатии кнопки «Подтвердить другим способом» мы получаем список методов, и с ним уже живём).

2) Недоступный метод, на который пришла ошибка, удаляется из кэшированного списка (иначе бы его снова можно было выбрать).

3) Соответственно, если недоступных методов будет несколько, то один за другим они будут удалены из кэша, пока не дойдём до всплывающего диалога об отсутствии доступных методов (из диаграммы выше). 

4) Остальные же нажатия кнопки подтверждения другим способом будут обновлять кэш.

«Всему своё время»: как надо и не надо реализовывать таймеры

Само собой, некоторые способы подтверждения, такие как SMS, требуют установки задержки перед повторной отправкой. Эти таймеры нам возвращает сервер. Далее вроде бы всё звучит просто: берём CounterDownTimer из Android SDK и завязываемся на его коллбек onTick. Но хочется рассказать про реальные проблемы, с которыми мы столкнулись в процессе разработки.

В первом варианте реализации таймера мы добавили его в onBindViewHolder (RecyclerView). Чуть позже (благо, это было на этапе разработки, спасибо замечательной библиотеке LeakCanary), мы заметили утечку памяти: при каждой привязке холдера создавался новый экземпляр таймера, который находился в MessageQueue и продолжал считать время до начала доступности каждого отдельного фактора даже тогда, когда пропадал с экрана.

Выглядело это так:

fun bind(type: MethodSelectorItem.VerificationType, callback: MethodSelectorCallback?) {
    // ...
    val timer = object : CountDownTimer(
        TimeUnit.SECONDS.toMillis(type.timeoutSeconds.toLong()),
        TimeUnit.SECONDS.toMillis(1)
    ) {
        override fun onTick(millisUntilFinished: Long) {
            if (!parent.isAttachedToWindow) cancel()// to prevent memory leak

            /* 
                Форматирование строки с учётом таймера
                Передача её в TextView для отображения
            */
        }

        override fun onFinish() {
            /* 
                Перевод TextView в состояние Enabled и установка текста уже без таймера
            */
        }
    }
    timer.start()
}

Плохое решение проблемы в этом коде было таким (никогда так не делайте):

if (!parent.isAttachedToWindow) cancel()// to prevent memory leak

Хорошее решение заключалось в том, чтобы переосмыслить логику работы таймера. Поскольку сервер возвращает нам массив факторов, в котором уже известен самый «долгий» из них, почему бы нам не создать один единственный таймер? Он будет считать начиная от того фактора, который будет доступен позже всех (иначе говоря, имеющего самый большой таймаут), и со временем «пробуждать» остальные факторы, когда те будут готовы.

Как мы реализовывали Custom View для ввода кода

Как можно заметить по скриншотам с подтверждением по SMS из старого и нового процессов, изменился UI-компонент ввода кода. Если раньше это было сплошное поле, то теперь стало несколько полей, количество которых зависит от длины кода. Сам компонент является специальной View, а каждая такая ячейка представляет собой EditTextвнутри контейнера. Неочевидный момент заключается в том, что итоговый код хранится в отдельном скрытом TextViewЭто было сделано для удобства взаимодействия с вводимым кодом. К примеру, так мы всегда получим вводимый код в виде строки с помощью одного только TextWatcher:

Иначе пришлось бы писать отдельные обёртки для взаимодействия с каждой ячейкой.

Второй момент заключается в логике вставки, удаления, замены и добавления кода. Введённый в любую ячейку код через стандартный коллбек onTextChangedпопадает в нашу специальную View, где уже обрабатывается в следующем методе onInput:  

override fun onInput(digits: String, position: Int) {
    val digits = digits.take(digitsCount)
    when {
        isPasteCommand(digits, position) -> paste(digits, position)
        isDeleteCommand(digits) -> delete(position)
        isAppendCommand(digits) -> append(digits, position)
    }
    currentTextHolder.text = currentText.toString()
    if (isInErrorState) {
        hideError()
    }
}

Зачем нужна такая логика? В каждый момент времени пользователь работает с какой-то одной из ячеек, а нам важно понять, как изменится текущий текст в остальных. Для начала мы берём максимальное количество цифр, которые могли прийти в ячейку. Это сделано из-за того, что в неё можно вставить из буфера обмена любую строку из цифр, но нас интересует только то количество, которое соответствует длине кода, то есть числу ячеек.

В конструкции when в зависимости от нужного действия выполняется либо вставка в ячейку нескольких цифр, либо добавление одной, либо же её удаление.

Автоматическая вставка кода из буфера обмена

Для удобства вставки кода из буфера обмена в коллбеке onStart фрагмент проверяется, является ли содержимое буфера кодом (или хотя бы строкой из чисел). Эта проверка срабатывает только если в текущих ячейках пусто (то есть код ещё не начинали вводить):

override fun onStart() {
    super.onStart()

    if (isOnStopCalled) { // проверяем, что происходило сворачивание приложения -> пользователь, предположительно, пошёл за кодом
        view?.post { presenter.onClipboardProcess() }
        isOnStopCalled = false
    }
}

override fun onClipboardProcess(): Boolean {
    val currentClipboard = clipboardManager.getCurrentClipboard()
    val couldBePastedFromClipboard = clipboardManager.isClipboardCouldBeProcessed(clipboard) && code.isBlank() // code - текущий код в ячейках
    if (
        couldBePastedFromClipboard
        && currentClipboard != null
    ) {
        messageProcessor.processMessage(currentClipboard) // обработка кода
        clipboardManager.setLastCopiedClipboard(currentClipboard)
    }
    return couldBePastedFromClipboard
}

ЗдесьClipboardManager обёрнут в декоратор со стандартной реализацией и некоторыми вспомогательными методами. 

Для удобства вставки в ячейки дополнительно переопределены коллбеки, отвечающие за появление (и обработку) меню при долгом нажатии на EditText — customSelectionActionModeCallback и customInsertionActionModeCallback. Заменяющий коллбек (реализует системный ActionMode.Callback) выглядит так:

internal class VkCheckActionModeCallback(
    private val otpClipboardManager: OtpClipboardManager,
    private val onPasteCallback: (String) -> Unit,
    private val digitsNum: Int
) : ActionMode.Callback {

    override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {
        val pasteItem = menu.findItem(android.R.id.paste) ?: return false
        menu.clear()
        menu.add(DEFAULT_GROUP_ID, android.R.id.paste, DEFAULT_ORDER, pasteItem.title)
        return true
    }

    override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
        return when (item?.itemId) {
            android.R.id.paste -> onPaste()
            else -> false
        }
    }

    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean = true
    override fun onDestroyActionMode(mode: ActionMode?) = Unit

    private fun onPaste(): Boolean {
        val clipboardText = otpClipboardManager.getCurrentClipboard() ?: return false
        return otpClipboardManager.isClipboardCouldBeProcessed(clipboardText).ifTrue {
            onPasteCallback(clipboardText.getCode(digitsNum))
        }
    }
    /* Различные константы */
}

Вставка в описанную ранее View для ячеек происходит в onPasteCallback, где вызывается onInput.

Кроме того

Параллельно с нами другая команда интегрировала один из самых современных способов входа на основе WebAuthn — OnePass. В какой-то момент перед нами встала задача интегрировать наработки друг друга, но подробнее  об этом расскажем как-нибудь в другой раз.

Кстати, о реализации мультиаккаунта можно почитать уже сейчас:

  1. https://habr.com/en/companies/vk/articles/769636/

  2. https://habr.com/en/companies/vk/articles/776728/

Благодарю за внимание и надеюсь, что наш опыт поможет вам!

© Habrahabr.ru