Подтверждение входа с помощью Telegram на Spring Boot

Недавно столкнулся с проблемой: все приложения используют Telegram-бота в качестве подтверждения входа в аккаунт, а мое — нет. Я был настроен серьезно и провёл уйму времени в интернете в поиске туториала, но меня ждало разочарование. Задача сложная и имеет много подводных камней, а туториалов — ноль.

Следующую неделю я потратил на написание своей имплементации данной фичи и готов поделиться успехом.

Весь код, который мы сегодня напишем, доступен в репозитории на GitHub. Рекомендую параллельно с чтением статьи проверять этот код в проекте, чтобы не упустить детали.

Создание проекта

Итак, для начала создадим проект. Для этого я использовал Spring Initializr. Для проекта нам понадобиться Spring MVC, Spring Security и Spring WebSocket. В качестве базы данных будем использовать H2. Мои настройки выглядели вот так:
Spring Initializr Settings

Затем в наш pom.xml добавим дополнительные зависимости: библиотека для работы с Telegram и webjars: bootstrap (для красивого дизайна), stomp-websocket и sockjs-client для работы с Spring WebSocket.

В итоге наш pom.xml будет выглядеть вот так.

Зависимости pom.xml

<dependencies>     <!-- SPRING -->     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-data-jpa</artifactId>     </dependency>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-security</artifactId>     </dependency>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-thymeleaf</artifactId>     </dependency>     <dependency>         <groupId>org.thymeleaf.extras</groupId>         <artifactId>thymeleaf-extras-springsecurity5</artifactId>     </dependency>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-web</artifactId>     </dependency>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-websocket</artifactId>     </dependency>     <dependency>         <groupId>org.springframework.security</groupId>         <artifactId>spring-security-messaging</artifactId>     </dependency>      <!-- DATABASE -->     <dependency>         <groupId>com.h2database</groupId>         <artifactId>h2</artifactId>         <scope>runtime</scope>     </dependency>      <!-- TELEGRAM -->     <dependency>         <groupId>org.telegram</groupId>         <artifactId>telegrambots</artifactId>         <version>4.8.1</version>     </dependency>     <dependency>         <groupId>org.telegram</groupId>         <artifactId>telegrambotsextensions</artifactId>         <version>4.8.1</version>     </dependency>      <!-- WEBJARS -->     <dependency>         <groupId>org.webjars</groupId>         <artifactId>bootstrap</artifactId>         <version>4.4.1-1</version>     </dependency>     <dependency>         <groupId>org.webjars</groupId>         <artifactId>stomp-websocket</artifactId>         <version>2.3.3</version>     </dependency>     <dependency>         <groupId>org.webjars</groupId>         <artifactId>sockjs-client</artifactId>         <version>1.0.2</version>     </dependency>      <!-- TESTS -->     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-test</artifactId>         <scope>test</scope>         <exclusions>             <exclusion>                 <groupId>org.junit.vintage</groupId>                 <artifactId>junit-vintage-engine</artifactId>             </exclusion>         </exclusions>     </dependency>     <dependency>         <groupId>org.springframework.security</groupId>         <artifactId>spring-security-test</artifactId>         <scope>test</scope>     </dependency> </dependencies>

Настройка базовой авторизации

Перейдём к настройке базовой авторизации. Сейчас наш класс настройки Web Security выглядит так, но позже он сильно измениться:

@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter {     private UserService userService;      @Autowired     public WebSecurityConfig(UserService userService) {         this.userService = userService;     }      @Override     protected void configure(HttpSecurity http) throws Exception {         http                 .csrf().and()                 .authorizeRequests()                 .antMatchers("/login").anonymous()                 .antMatchers("/resource/**", "/webjars/**", "/websocket/**").permitAll()                 .antMatchers("/**").authenticated()                 .and()                 .formLogin()                 .loginPage("/login")                 .and()                 .logout()                 .logoutUrl("/logout")                 .logoutSuccessUrl("/login?logout");     }      @Override     public void configure(WebSecurity web) throws Exception {         web.ignoring().antMatchers("/static/**", "/webjars/**");     }      @Override     protected void configure(AuthenticationManagerBuilder auth) throws Exception {         auth.userDetailsService(userService).passwordEncoder(passwordEncoder());     }      @Bean     public PasswordEncoder passwordEncoder() {         return PasswordEncoderFactories.createDelegatingPasswordEncoder();     } }

Вы можете обратить внимание, что мы сказали Spring’у, что он должен разрешать запрос /websocket/** всем. Пока что в этом нет никакого смысла, но в будущем это будет очень важная строка.

Перепись авторизации на JSON формат

Наше приложение будет работать по такому алгоритму:
Алгоритм авторизации

Как вы видите, тут проверка пользователя идёт по порядку: спросили одно и ждём ответа, при получении ответа, если надо, спрашиваем второе. Перенаправлять пользователя со страницы на страницу было бы не очень удобно, а вот технологию AJAX применить можно было бы. Для этого перепишем нашу авторизацию на JSON. Делать мы это будем с помощью AuthenticationSuccessHandler и AuthenticationFailureHandler, но для начала создадим модель, которую будем возвращать в качестве информации об авторизации:

public class AuthenticationInfo {     private boolean success;     private String redirectUrl;     private String errorMessage;     private Set<RequiredMfa> requiredMfas;      public enum RequiredMfa {         TELEGRAM_MFA     }      // getters, setters and no args constructor }

Как вы видите, тут мы используем Set<RequiredMfa> requiredMfas вместо простого boolean askTelegramMfa. Как вы думаете почему?

Ответ

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

Теперь напишем RequireTelegramMfaException. Это Exception, который мы будем выбрасывать, если пользователь должен авторизоваться с помощью Telegram. Почему? Пока пользователь не подтвердил авторизацию в Telegram, мы не должны его авторизовать, а значит мы должны выбросить Exception, что бы Spring этого не сделал. Почему мы пишем его сейчас? Далее мы напишем CustomAuthenticationFailureHandler, который будет проверять эту ошибку.

Наш RequireTelegramMfaException обязательно должен наследоваться от AuthenticationException, а выглядеть будет так:

public class RequireTelegramMfaException extends AuthenticationException {     public RequireTelegramMfaException(String msg) {         super(msg);     } }

Теперь перейдём непосредственно к CustomAuthenticationFailureHandler. Наш код будет выглядеть вот так:

public class CustomFailureHandler implements AuthenticationFailureHandler {     private ObjectMapper objectMapper = new ObjectMapper();      @Override     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {         AuthenticationInfo info = new AuthenticationInfo();         info.setSuccess(false);         info.setErrorMessage(e.getMessage());          if (e instanceof RequireTelegramMfaException) {             info.setRequiredMfas(Collections.singleton(TELEGRAM_MFA));         }          response.setCharacterEncoding(CharEncoding.UTF_8);         response.setStatus(HttpStatus.UNAUTHORIZED.value());         response.setContentType(MediaType.APPLICATION_JSON_VALUE);         objectMapper.writeValue(response.getWriter(), info);     } }

Тут всё просто: в начале мы создаем и настраиваем AuthenticationInfo, говорим, что авторизация была не успешна, ошибку берём из Exception и если наш Exception — это RequireTelegramMfaException говорим, что пользователь ещё должен подтвердить авторизацию в Telegram. Затем мы уже просто настраиваем ответ: ставим character encoding = UTF-8, отправляем статус 401 (UNAUTHORIZE) и ставим content type = application/json. Затем просто возвращаем наш AuthenticationInfo.

Наш CustomSuccessHandler будет возвращать AuthenticationInfo с параметром success=true и указывать адрес, на который надо перенаправлять после авторизации (последняя открытая страница). Статус ответа будет 200 (OK). Делается это вот так:

public class CustomSuccessHandler implements AuthenticationSuccessHandler {     private RequestCache requestCache = new HttpSessionRequestCache();     private ObjectMapper objectMapper = new ObjectMapper();      @Override     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {         AuthenticationInfo info = new AuthenticationInfo();         info.setSuccess(true);         info.setRedirectUrl(getRedirectUrl(request, response));          response.setCharacterEncoding(CharEncoding.UTF_8);         response.setStatus(HttpStatus.OK.value());         response.setContentType(MediaType.APPLICATION_JSON_VALUE);         objectMapper.writeValue(response.getWriter(), info);     }      public String getRedirectUrl(HttpServletRequest request, HttpServletResponse response) {         SavedRequest cache = requestCache.getRequest(request, response);         return cache == null ? "/" : cache.getRedirectUrl();     }      public void setRequestCache(RequestCache requestCache) {         this.requestCache = requestCache;     } }

Как вы видите, redirectUrl мы берём из объекта RequestCache, который мы можем взять у объекта HttpSecurity. Наш новый WebSecurityConfig будет выглядеть вот так (в будущем мы будем его редактировать ещё один раз):

@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter {     // fields declarations and constructor      @Override     protected void configure(HttpSecurity http) throws Exception {         http                 .csrf().and()                 .authorizeRequests()                 .antMatchers("/login").anonymous()                 .antMatchers("/resource/**", "/webjars/**", "/websocket/**").permitAll()                 .antMatchers("/**").authenticated()                 .and()                 .formLogin()                  // ставим только что написанные нами handler'ы                 .failureHandler(authenticationFailureHandler())                 .successHandler(authenticationSuccessHandler())                  .loginPage("/login")                 .and()                 .logout()                 .logoutUrl("/logout")                 .logoutSuccessUrl("/login?logout");          // запрашиваем у HttpSecurity объект RequestCache         // если он не null - передаём его в наш CustomSuccessHandler         RequestCache requestCache = http.getSharedObject(RequestCache.class);         if (requestCache != null) {             authenticationSuccessHandler().setRequestCache(requestCache);         }     }      @Override     public void configure(WebSecurity web) throws Exception {         web.ignoring().antMatchers("/static/**", "/webjars/**");     }      @Override     protected void configure(AuthenticationManagerBuilder auth) throws Exception {         auth.userDetailsService(userService).passwordEncoder(passwordEncoder());     }      @Bean     public PasswordEncoder passwordEncoder() {         return PasswordEncoderFactories.createDelegatingPasswordEncoder();     }      @Bean     public CustomSuccessHandler authenticationSuccessHandler() {         return new CustomSuccessHandler();     }      @Bean     public CustomFailureHandler authenticationFailureHandler() {         return new CustomFailureHandler();     } }

После этого наш frontend нужно переписать на систему AJAX. В этом примере я использовал Bootstrap Carousel, а авторизацию переписал вот так (код html и js):

document.addEventListener('DOMContentLoaded', () => {     loginForm.addEventListener('submit', e => {         e.preventDefault();          $.ajax({             method: 'POST',             url: '/login',             data: $(loginForm).serialize(),             error: response => {                 let data = response.responseJSON;                  // если требуется подтверждение авторизации в Telegram -                 // открыть нужный слайд, где будет сообщение об этом                 if (data.requiredMfas                          && data.requiredMfas.includes('TELEGRAM_MFA')) {                     $(carousel).carousel(TELEGRAM_SLIDE);                 } else { // иначе - выводим сообщение ошибки                     showAlert(data.errorMessage, 'danger');                     loginForm.querySelector('input[name="password"]').value = '';                 }             }         }).done(response => {             loginModal.classList.add('fullscreen-loading-modal'); // запускаем анимацию загрузки             location.href = response.redirectUrl; // перенаправляем пользователя         });     }); }

Хотелось бы отметить, почему мы не проверяем параметр success: в своих handler’ах мы указываем нужные статусы ответа (200, если авторизован и 401, если не авторизован). Благодаря этому, jQuery сам направит response в нужный callback: error, если ошибка (в нашем случае, ответ 401) или done, если всё нормально (в нашем случае, ответ 200).

Создание и настройка Telegram бота

Теперь займёмся самим Telegram ботом. В нашем pom.xml должны быть две зависимости: наркотическая и никотиновая org.telegram:telegrambots и org.telegram:telegrambotsextensions. Код нашего бота будет выглядеть вот так:

@Component public class TelegramBot extends TelegramLongPollingCommandBot {     private String botUsername;     private String botToken;      public TelegramBot(Environment env, ConnectAccountCommand connectAccountCommand) throws TelegramApiException {         super(ApiContext.getInstance(DefaultBotOptions.class), false);         this.botToken = env.getRequiredProperty("telegram.bot.token");         this.botUsername = getMe().getUserName();          register(connectAccountCommand);     }      @PostConstruct     public void addBot() throws TelegramApiRequestException {         TelegramBotsApi botsApi = new TelegramBotsApi();         botsApi.registerBot(this);     }      @Override     public void processNonCommandUpdate(Update update) {      }      @Override     public String getBotUsername() {         return botUsername;     }      @Override     public String getBotToken() {         return botToken;     } }

Тут мы в конструкторе вызываем базовые настройки, где говорим, что мы не хотим работать с командами для бота, которые были вызваны с помощью имени бота (пример: /start@myBot). Так же мы регистрируем ConnectAccountCommand. Это команда, для подключения вашего аккаунта на сайте к аккаунту Telegram. Также, вы можете зарегистрировать команду /start, для вывода стартового сообщения. Метод addBot() помечен аннотацией @PostConstruct. Это значит, что этот метод будет вызван, когда Bean компонент уже сконфигурирован. Тут мы просто создаем TelegramBotsApi и добавляем туда нашего бота.

ConnectAccountCommand

@Component public class ConnectAccountCommand extends BotCommand {     private static final Logger log = LoggerFactory.getLogger(ConnectAccountCommand.class);     private UserService userService;      public ConnectAccountCommand(UserService userService) {         super("connect", "Команда для подключения аккаунта");         this.userService = userService;     }      @Override     public void execute(AbsSender sender, User user, Chat chat, String[] strings) {         String username = strings[0];         userService.connectBot(username, chat.getId());          SendMessage message = new SendMessage()                 .setChatId(chat.getId())                 .setText("Вы успешно подключили бота!");         try {             sender.execute(message);         } catch (TelegramApiException e) {             log.error("Error sending success telegram bot connect message", e);         }     } }

Подтверждение авторизации с помощью Telegram бота

Перейдём к написанию самой авторизации с помощью Telegram. Для начала нам понадобиться свой WebAuthenticationDetails. Нам понадобиться HttpServletRequest, мы будем с ним работать. Наш CustomWebAuthenticationDetails будет выглядеть вот так:

public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {     private final HttpServletRequest request;      public CustomWebAuthenticationDetails(HttpServletRequest request) {         super(request);         this.request = request;     }      public HttpServletRequest getRequest() {         return request;     } }

Теперь нам понадобиться свой AuthenticationProvider, который будет проверять нужно ли подтверждение с помощью Telegram и если да — отправлять сообщение в Telegram и сообщать об этом пользователю. Мы будем наследоваться от класса DaoAuthenticationProvider. У него есть метод additionalAuthenticationChecks(UserDetails, UsernamePasswordAuthenticationToken). Он может делать проверку пользователя уже после того, как мы проверили логин и пароль пользователя.

public class CustomAuthenticationProvider extends DaoAuthenticationProvider {     @Override     protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {         HttpServletRequest request = ((CustomWebAuthenticationDetails) authentication.getDetails()).getRequest();         AuthorizedUser authUser = (AuthorizedUser) userDetails;         User user = authUser.getUser();          if (user.getTelegramChatId() != null) {             // TODO: send telegram confirm message             throw new RequireTelegramMfaException("Пожалуйста, подтвердите вход в Telegram!");         }          super.additionalAuthenticationChecks(userDetails, authentication);     }      @Override     public boolean supports(Class<?> authentication) {         return authentication.equals(UsernamePasswordAuthenticationToken.class);     } }

Создадим команду Telegram бота для подтверждения авторизации MfaCommand:

@Component public class MfaCommand {     private static final Logger log = LoggerFactory.getLogger(MfaCommand.class);     private static final String CONFIRM_BUTTON = "confirm";     private Map<Long, AuthInfo> connectingUser = new HashMap<>();     private TelegramBot telegramBot;     private WebSocketService webSocketService;     private CustomSuccessHandler customSuccessHandler;      @Autowired     public MfaCommand(TelegramBot telegramBot, WebSocketService webSocketService, @Lazy CustomSuccessHandler customSuccessHandler) {         this.telegramBot = telegramBot;         this.webSocketService = webSocketService;         this.customSuccessHandler = customSuccessHandler;     }      // теперь, наш CustomAuthenticationProvider будет вызывать этот метод     public void requireMfa(Authentication authentication, SecurityContext context, HttpServletRequest request) {         User user = ((AuthorizedUser) authentication.getPrincipal()).getUser();          // Мы создаём объект AuthInfo и кладём его в нашу мапу         // В качестве ключа используем chat id         // CSRF токен нам понадобиться позже         String csrfToken = request.getParameter("_csrf");         HttpSession session = request.getSession(true);          // Если продебажить код - мы увидим,         // что Spring использует HttpSessionRequestCache         // в качестве RequestCache, а посмотрев его исходный код мы увидим,         // что HttpServletResponse он никак не использует.         // Поэтому мы можем передавать туда null         String redirectUrl = customSuccessHandler.getRedirectUrl(request, null);          AuthInfo authInfo = new AuthInfo(authentication, context, session, csrfToken, redirectUrl);         connectingUser.put(user.getTelegramChatId(), authInfo);          sendUserMessage(user);     }      // Когда пользователь нажмёт кнопку отклонить или подтвердить -     // наш Telegram бот вызовет этот метод     public void onCallbackQuery(CallbackQuery callbackQuery) {         Message message = callbackQuery.getMessage();          // Ищем наш AuthInfo по chat id, извлекаем и удаляем его          AuthInfo authInfo = connectingUser.remove(message.getChatId());          EditMessageText editMessageText = new EditMessageText()                 .setChatId(message.getChatId())                 .setMessageId(message.getMessageId());          AuthenticationInfo info = new AuthenticationInfo();          // Если пользователь нажал кнопку "Подтвердить"         if (callbackQuery.getData().equals(CONFIRM_BUTTON)) {             // Берем авторизацию,              // которая к нам пришла из CustomAuthenticationProvider             Authentication authentication = authInfo.getAuthentication();              // Устанавливаем её в SecurityContext,              // который нам пришёл из CustomAuthenticationProvider             authInfo.getSecurityContext().setAuthentication(authentication);              // И записываем авторизацию в сессию браузера             authInfo.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, authInfo.getSecurityContext());              // Редактируем сообщение и заполняем AuthenticationInfo             editMessageText.setText("Вы успешно подтвердили вход!");             info.setSuccess(true);             info.setRedirectUrl(authInfo.getRedirectUrl());         } else { // Если пользователь нажал кнопку "Отклонить"             // Тогда просто редактируем сообщение и заполняем AuthenticationInfo,             // где говорим, что авторизация не прошла успешно             editMessageText.setText("Вы успешно отклонили вход!");             info.setSuccess(false);             info.setErrorMessage("Вы отклонили вход в Telegram");         }          // TODO: send browser notification          try {             telegramBot.execute(editMessageText);         } catch (TelegramApiException e) {             log.error("Error updating telegram MFA message", e);         }     }      private void sendUserMessage(User user) {         InlineKeyboardButton confirmButton = new InlineKeyboardButton("Подтвердить");         confirmButton.setCallbackData(CONFIRM_BUTTON);          InlineKeyboardButton declineButton = new InlineKeyboardButton("Отклонить");         declineButton.setCallbackData("decline");          InlineKeyboardMarkup markup = new InlineKeyboardMarkup(                 Collections.singletonList(                         Arrays.asList(confirmButton, declineButton)                 )         );          SendMessage sendMessage = new SendMessage()                 .setChatId(user.getTelegramChatId())                 .setText("Подтвердите вход в аккаунт <b>" + user.getUsername() + "</b>")                 .setParseMode("HTML")                 .setReplyMarkup(markup);          try {             telegramBot.execute(sendMessage);         } catch (TelegramApiException e) {             log.error("Error sending telegram MFA message", e);         }     }      private static class AuthInfo {         private final Authentication authentication;         private final SecurityContext securityContext;         private final HttpSession session;         private final String csrf;         private final String redirectUrl;          // all args constructor and getters     } }

Перепишем if в методе additionalAuthenticationChecks() класса CustomAuthenticationProvider.

if (user.getTelegramChatId() != null) {     UsernamePasswordAuthenticationToken authenticationToken =             new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());     mfaCommand.requireMfa(authenticationToken, SecurityContextHolder.getContext(), request);     throw new RequireTelegramMfaException("Пожалуйста, подтвердите вход в Telegram!"); }

И обновим метод processNonCommandUpdate(Update) класса TelegramBot:

@Override public void processNonCommandUpdate(Update update) {     if (update.hasCallbackQuery()) {         mfaCommand.onCallbackQuery(update.getCallbackQuery());     } }

Теперь обновим WebSecurityConfig где добавим парсер AuthenticationDetais в CustomWebAuthenticationDetails и наш новый CustomAuthenticationProvider.

@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter {     private UserService userService;     private MfaCommand mfaCommand;      @Autowired     public WebSecurityConfig(UserService userService, MfaCommand mfaCommand) {         this.userService = userService;         this.mfaCommand = mfaCommand;     }      @Override     protected void configure(HttpSecurity http) throws Exception {         http                 .csrf().and()                 .authorizeRequests()                 .antMatchers("/login").anonymous()                 .antMatchers("/webjars/**", "/resource/**", "/websocket/**").permitAll()                 .antMatchers("/**").authenticated()                 .and()                 .formLogin()                  // парсер AuthenticationDetails в CustomWebAuthenticationDetails                 // аналог: details -> new CustomWebAuthenticationDetails(details)                 .authenticationDetailsSource(CustomWebAuthenticationDetails::new)                  .failureHandler(authenticationFailureHandler())                 .successHandler(authenticationSuccessHandler())                 .loginPage("/login")                 .and()                 .logout()                 .logoutUrl("/logout")                 .logoutSuccessUrl("/login?logout");          RequestCache requestCache = http.getSharedObject(RequestCache.class);         if (requestCache != null) {             authenticationSuccessHandler().setRequestCache(requestCache);         }     }      // WebSecurity configure      @Override     protected void configure(AuthenticationManagerBuilder auth) throws Exception {         auth.authenticationProvider(customAuthenticationProvider());     }      @Bean     public CustomAuthenticationProvider customAuthenticationProvider() {         var provider = new CustomAuthenticationProvider(mfaCommand);         provider.setUserDetailsService(userService);         provider.setPasswordEncoder(passwordEncoder());         return provider;     }      // old beans }

На этом этапе можете запустить приложение. Если у вас подключён Telegram аккаунт — бот отправит вам сообщение для подтверждения. Если вы нажмёте кнопку "Подтвердить" — вас авторизуют, но вы этого не увидите. Для этого обновите страницу в браузере или, что лучше, перейдите на главную страницу (к странице авторизации доступа у вас уже не будет, так как вы авторизованы).

Отправка сообщение в браузер, об успешной авторизации

Последнее, что осталось сделать — потанцевать с бубном написать уведомление для браузера. Мы уже авторизуем браузер при подтверждении, но браузер об этом не знает. Мы должны вручную обновлять страничку, чтобы увидеть это. От этого будем пытаться избавиться.

Начнём сразу с проблемы, которая у нас встречается: мы можем подключать Spring WebSocket, но как мы будем ему говорить, кому отправлять сообщение? Пользователь подключается ещё до авторизации, так что его логин ещё неизвестен. Если при подключении пользователь не авторизован, в метод simpMessagingTemplate.convertAndSendToUser нужно передавать Session ID в качестве String user параметра: ID подключения. Однако, Session ID, который к нам приходит в HttpServletRequest, отличается от Session ID, который к нам приходит при подключении к WebSocket.

Сделаем свой репозиторий, который будет хранить Session ID подключения WebSocket. Но как мы будем аутентифицировать пользователя? В качестве решения решил использовать CSRF токен. Для подключения WebSocket, нам нужно указать CSRF токен, для запросов — тоже. Перейдём к реализации:

@Component public class WebSocketSessionStorage              implements ApplicationListener<SessionConnectEvent> {     private Map<String, String> storage = new HashMap<>();      @Override     public void onApplicationEvent(SessionConnectEvent event) {         StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());          // берём Session ID подключения...         String sessionId = sha.getSessionId();          // ...и CSRF токен         List<String> nativeHeader = sha.getNativeHeader("X-CSRF-TOKEN");          if (nativeHeader != null && nativeHeader.size() != 0) {             // и кладём его в наш репозиторий             storage.put(nativeHeader.get(0), sessionId);         }     }      public String getSessionId(String csrf) {         return storage.remove(csrf);     } }

Теперь создадим WebSocketService, который будет отправлять сообщение для бразура. Он будет принимать информацию об авторизации и CSRF токен.

@Service public class WebSocketService {     private SimpMessagingTemplate simpMessagingTemplate;     private WebSocketSessionStorage sessionStorage;      // all arg constructor      public void sendLoginStatus(AuthenticationInfo info, String csrf) {         // ищем Session ID, используя CSRF токен         String sessionId = sessionStorage.getSessionId(csrf);          var headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);         headerAccessor.setSessionId(sessionId);         headerAccessor.setLeaveMutable(true);          // отправляем сообщения по адресу "/queue/login"         simpMessagingTemplate.convertAndSendToUser(sessionId, "/queue/login", info, headerAccessor.getMessageHeaders());     } }

Добавим вызов этого метода в MfaCommand:

public void onCallbackQuery(CallbackQuery callbackQuery) {     Message message = callbackQuery.getMessage();     AuthInfo authInfo = connectingUser.remove(message.getChatId());      // ...      AuthenticationInfo info = new AuthenticationInfo();      if (callbackQuery.getData().equals(CONFIRM_BUTTON)) {         // ...     }      // отправляем уведомление для браузера     webSocketService.sendLoginStatus(info, authInfo.getCsrf());      try {         telegramBot.execute(editMessageText);     } catch (TelegramApiException e) {         log.error("Error updating telegram MFA message", e);     } }

Создадим WebSocketConfig и WebSocketSecurityConfiguration:

@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {     @Override     public void configureMessageBroker(MessageBrokerRegistry registry) {         registry.enableSimpleBroker("/queue/login");         registry.setApplicationDestinationPrefixes("/ws");     }      @Override     public void registerStompEndpoints(StompEndpointRegistry registry) {         registry                 .addEndpoint("/websocket")                 .setAllowedOrigins("*")                 .withSockJS();     } }  @Configuration public class WebSocketSecurityConfiguration                  extends AbstractSecurityWebSocketMessageBrokerConfigurer {     @Override     protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {         messages                 // говорим, что подключаться,                 // отключаться и отписываться - могу все                 .simpTypeMatchers(                             SimpMessageType.CONNECT,                              SimpMessageType.DISCONNECT,                              SimpMessageType.UNSUBSCRIBE                         ).permitAll()                  // слушать информацию об авторизации                 // могут только не авторизованные пользователи                 .simpSubscribeDestMatchers("/user/queue/login").anonymous()                  // все остальные сообщения -                 // только для авторизованных пользователей                 .anyMessage().authenticated();     } }

Напоню, что в WebSecurityConfig должно стоять .antMatchers("/websocket/**").permitAll(). Это очень важно. Иначе, неавторизованный пользователь не сможет подключиться.

Осталось только переписать frontend, что бы он ловил эти сообщения и делал какие-то действия, в зависимости от содержимого:

let socket = new SockJS('/websocket'); let stompClient = Stomp.over(socket);  // создаем объект headers, куда кладём все Header'ы, // необходимые для подключения let headers = {};  // кладём CSRF токен // по нему мы и будем авторизовывать нашего пользователя // а без него - WebSecurity запретит подключение let csrfHeader = document.querySelector('meta[name="_csrf_header"]').content; headers[csrfHeader] = document.querySelector('meta[name="_csrf"]').content;  // подключаемся к WebSocket серверу stompClient.connect(headers, frame => {     console.log(frame);      // подписываемся на уведомления по авторизации     stompClient.subscribe('/user/queue/login', data => {         const info = JSON.parse(data.body);          // если авторизация успешна -          // выводим анимацию загрузки         // и открываем нужную страницу         if (info.success) {             loginModal.classList.add('fullscreen-loading-modal');             location.href = info.redirectUrl;         } else {             // иначе - выводим ошибку              // и открываем слайд ввода логина и пароля             loginForm.querySelector('input[name="password"]').value = '';             $(carousel).carousel(LOGIN_SLIDE);             showAlert(info.errorMessage, 'danger');         }     }); });

Наше приложение готово! Напомню, что весь исходный код лежит в GitHub репозитории. Вы можете ознакомиться с ним ещё раз или попробовать запустить приложение.

Также, в этом приложении мы это не рассматривали, но, для продакшона, вам, скорее всего, понадобиться реализовать функцию "Запомнить меня". Дело в том, что если пользователь использует вход с помощью телеграма — приложение не сможет добавить ему remember me cookie. Для исправления этого, вы можете записывать cookie с помощью JavaScript или, к примеру, перенаправлять пользователя на страничку с параметром ?rememberMe=true, а затем, используя Filter, проверять на наличие этого параметра и, если это необходимо, записывать cookie.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *