[recovery mode] Подтверждение входа с помощью 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

    
    
        org.springframework.boot
        spring-boot-starter-data-jpa
    
    
        org.springframework.boot
        spring-boot-starter-security
    
    
        org.springframework.boot
        spring-boot-starter-thymeleaf
    
    
        org.thymeleaf.extras
        thymeleaf-extras-springsecurity5
    
    
        org.springframework.boot
        spring-boot-starter-web
    
    
        org.springframework.boot
        spring-boot-starter-websocket
    
    
        org.springframework.security
        spring-security-messaging
    

    
    
        com.h2database
        h2
        runtime
    

    
    
        org.telegram
        telegrambots
        4.8.1
    
    
        org.telegram
        telegrambotsextensions
        4.8.1
    

    
    
        org.webjars
        bootstrap
        4.4.1-1
    
    
        org.webjars
        stomp-websocket
        2.3.3
    
    
        org.webjars
        sockjs-client
        1.0.2
    

    
    
        org.springframework.boot
        spring-boot-starter-test
        test
        
            
                org.junit.vintage
                junit-vintage-engine
            
        
    
    
        org.springframework.security
        spring-security-test
        test
    


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

Перейдём к настройке базовой авторизации. Сейчас наш класс настройки 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 requiredMfas;

    public enum RequiredMfa {
        TELEGRAM_MFA
    }

    // getters, setters and no args constructor
}

Как вы видите, тут мы используем Set 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 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("Подтвердите вход в аккаунт " + user.getUsername() + "")
                .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 {
    private Map storage = new HashMap<>();

    @Override
    public void onApplicationEvent(SessionConnectEvent event) {
        StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());

        // берём Session ID подключения...
        String sessionId = sha.getSessionId();

        // ...и CSRF токен
        List 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.

© Habrahabr.ru