Недавно столкнулся с проблемой: все приложения используют Telegram-бота в качестве подтверждения входа в аккаунт, а мое — нет. Я был настроен серьезно и провёл уйму времени в интернете в поиске туториала, но меня ждало разочарование. Задача сложная и имеет много подводных камней, а туториалов — ноль. Следующую неделю я потратил на написание своей имплементации данной фичи и готов поделиться успехом. Весь код, который мы сегодня напишем, доступен в репозитории на GitHub. Рекомендую параллельно с чтением статьи проверять этот код в проекте, чтобы не упустить детали. Итак, для начала создадим проект. Для этого я использовал Spring Initializr. Для проекта нам понадобится Spring MVC, Spring Security и Spring WebSocket. В качестве базы данных будем использовать H2. Мои настройки выглядели вот так:
 Затем в наш pom.xml добавим дополнительные зависимости: библиотека для работы с Telegram и webjars: bootstrap (для красивого дизайна), stomp-websocket и sockjs-client для работы с Spring WebSocket. В итоге наш pom.xml будет выглядеть вот так. Перейдём к настройке базовой авторизации. Сейчас наш класс настройки Web Security выглядит так, но позже он сильно измениться: Вы можете обратить внимание, что мы сказали Spring'у, что он должен разрешать запрос /websocket/** всем. Пока что в этом нет никакого смысла, но в будущем это будет очень важная строка. Наше приложение будет работать по такому алгоритму:
 Как вы видите, тут проверка пользователя идёт по порядку: спросили одно и ждём ответа, при получении ответа, если надо, спрашиваем второе. Перенаправлять пользователя со страницы на страницу было бы не очень удобно, а вот технологию AJAX применить можно было бы. Для этого перепишем нашу авторизацию на JSON. Делать мы это будем с помощью AuthenticationSuccessHandler и AuthenticationFailureHandler, но для начала создадим модель, которую будем возвращать в качестве информации об авторизации: Как вы видите, тут мы используем Set<RequiredMfa> requiredMfas вместо простого boolean askTelegramMfa. Как вы думаете почему? Действительно, в данном примерочном проекте большого смысла нет. Однако тут я ориентируюсь на большие проекты где, помимо Telegram подтверждения, пользователи могут использовать разные способы подтверждения (больше одного). Теперь напишем RequireTelegramMfaException. Это Exception, который мы будем выбрасывать, если пользователь должен авторизоваться с помощью Telegram. Почему? Пока пользователь не подтвердил авторизацию в Telegram, мы не должны его авторизовать, а значит мы должны выбросить Exception, что бы Spring этого не сделал. Почему мы пишем его сейчас? Далее мы напишем CustomAuthenticationFailureHandler, который будет проверять эту ошибку. Наш RequireTelegramMfaException обязательно должен наследоваться от AuthenticationException, а выглядеть будет так: Теперь перейдём непосредственно к CustomAuthenticationFailureHandler. Наш код будет выглядеть вот так: Тут всё просто: в начале мы создаем и настраиваем AuthenticationInfo, говорим, что авторизация была не успешна, ошибку берём из Exception и если наш Exception — это RequireTelegramMfaException говорим, что пользователь ещё должен подтвердить авторизацию в Telegram. Затем мы уже просто настраиваем ответ: ставим character encoding = UTF-8, отправляем статус 401 (UNAUTHORIZE) и ставим content type = application/json. Затем просто возвращаем наш AuthenticationInfo. Наш CustomSuccessHandler будет возвращать AuthenticationInfo с параметром success=true и указывать адрес, на который надо перенаправлять после авторизации (последняя открытая страница). Статус ответа будет 200 (OK). Делается это вот так: Как вы видите, redirectUrl мы берём из объекта RequestCache, который мы можем взять у объекта HttpSecurity. Наш новый WebSecurityConfig будет выглядеть вот так (в будущем мы будем его редактировать ещё один раз): После этого наш frontend нужно переписать на систему AJAX. В этом примере я использовал Bootstrap Carousel, а авторизацию переписал вот так (код html и js): Хотелось бы отметить, почему мы не проверяем параметр success: в своих handler'ах мы указываем нужные статусы ответа (200, если авторизован и 401, если не авторизован). Благодаря этому, jQuery сам направит response в нужный callback: error, если ошибка (в нашем случае, ответ 401) или done, если всё нормально (в нашем случае, ответ 200). Теперь займёмся самим Telegram ботом. В нашем pom.xml должны быть две зависимости: наркотическая и никотиновая org.telegram:telegrambots и org.telegram:telegrambotsextensions. Код нашего бота будет выглядеть вот так: Тут мы в конструкторе вызываем базовые настройки, где говорим, что мы не хотим работать с командами для бота, которые были вызваны с помощью имени бота (пример: /start@myBot). Так же мы регистрируем ConnectAccountCommand. Это команда, для подключения вашего аккаунта на сайте к аккаунту Telegram. Также, вы можете зарегистрировать команду /start, для вывода стартового сообщения. Метод addBot() помечен аннотацией @PostConstruct. Это значит, что этот метод будет вызван, когда Bean компонент уже сконфигурирован. Тут мы просто создаем TelegramBotsApi и добавляем туда нашего бота. Перейдём к написанию самой авторизации с помощью Telegram. Для начала нам понадобится свой WebAuthenticationDetails. Нам понадобится HttpServletRequest, мы будем с ним работать. Наш CustomWebAuthenticationDetails будет выглядеть вот так:  Теперь нам понадобится свой AuthenticationProvider, который будет проверять нужно ли подтверждение с помощью Telegram и если да — отправлять сообщение в Telegram и сообщать об этом пользователю. Мы будем наследоваться от класса DaoAuthenticationProvider. У него есть метод additionalAuthenticationChecks(UserDetails, UsernamePasswordAuthenticationToken). Он может делать проверку пользователя уже после того, как мы проверили логин и пароль пользователя. Создадим команду Telegram бота для подтверждения авторизации MfaCommand: Перепишем if в методе additionalAuthenticationChecks() класса CustomAuthenticationProvider. И обновим метод processNonCommandUpdate(Update) класса TelegramBot: Теперь обновим WebSecurityConfig где добавим парсер AuthenticationDetais в CustomWebAuthenticationDetails и наш новый CustomAuthenticationProvider. На этом этапе можете запустить приложение. Если у вас подключён Telegram аккаунт — бот отправит вам сообщение для подтверждения. Если вы нажмёте кнопку "Подтвердить" — вас авторизуют, но вы этого не увидите. Для этого обновите страницу в браузере или, что лучше, перейдите на главную страницу (к странице авторизации доступа у вас уже не будет, так как вы авторизованы). Последнее, что осталось сделать — потанцевать с бубном написать уведомление для браузера. Мы уже авторизуем браузер при подтверждении, но браузер об этом не знает. Мы должны вручную обновлять страничку, чтобы увидеть это. От этого будем пытаться избавиться. Начнём сразу с проблемы, которая у нас встречается: мы можем подключать Spring WebSocket, но как мы будем ему говорить, кому отправлять сообщение? Пользователь подключается ещё до авторизации, так что его логин ещё неизвестен. Если при подключении пользователь не авторизован, в метод simpMessagingTemplate.convertAndSendToUser нужно передавать Session ID в качестве String user параметра: ID подключения. Однако, Session ID, который к нам приходит в HttpServletRequest, отличается от Session ID, который к нам приходит при подключении к WebSocket. Сделаем свой репозиторий, который будет хранить Session ID подключения WebSocket. Но как мы будем аутентифицировать пользователя? В качестве решения решил использовать CSRF токен. Для подключения WebSocket, нам нужно указать CSRF токен, для запросов — тоже. Перейдём к реализации: Теперь создадим WebSocketService, который будет отправлять сообщение для бразура. Он будет принимать информацию об авторизации и CSRF токен. Добавим вызов этого метода в MfaCommand: Создадим WebSocketConfig и WebSocketSecurityConfiguration: Напоню, что в WebSecurityConfig должно стоять .antMatchers("/websocket/**").permitAll(). Это очень важно. Иначе, неавторизованный пользователь не сможет подключиться. Осталось только переписать frontend, что бы он ловил эти сообщения и делал какие-то действия, в зависимости от содержимого: Наше приложение готово! Напомню, что весь исходный код лежит в GitHub репозитории. Вы можете ознакомиться с ним ещё раз или попробовать запустить приложение. Также, в этом приложении мы это не рассматривали, но, для продакшона, вам, скорее всего, понадобится реализовать функцию "Запомнить меня". Дело в том, что если пользователь использует вход с помощью телеграма — приложение не сможет добавить ему remember me cookie. Для исправления этого, вы можете записывать cookie с помощью JavaScript или, к примеру, перенаправлять пользователя на страничку с параметром ?rememberMe=true, а затем, используя Filter, проверять на наличие этого параметра и, если это необходимо, записывать cookie.