Недавно столкнулся с проблемой: все приложения используют Telegram-бота в качестве подтверждения входа в аккаунт, а мое — нет. Я был настроен серьезно и провёл уйму времени в интернете в поиске туториала, но меня ждало разочарование. Задача сложная и имеет много подводных камней, а туториалов — ноль.
Следующую неделю я потратил на написание своей имплементации данной фичи и готов поделиться успехом.
Весь код, который мы сегодня напишем, доступен в репозитории на GitHub. Рекомендую параллельно с чтением статьи проверять этот код в проекте, чтобы не упустить детали.
Создание проекта
Итак, для начала создадим проект. Для этого я использовал Spring Initializr. Для проекта нам понадобится Spring MVC, Spring Security и Spring WebSocket. В качестве базы данных будем использовать H2. Мои настройки выглядели вот так:
Затем в наш pom.xml
добавим дополнительные зависимости: библиотека для работы с Telegram и webjars: bootstrap
(для красивого дизайна), stomp-websocket
и sockjs-client
для работы с Spring WebSocket.
В итоге наш 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
и добавляем туда нашего бота.
@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
.
gkislin
Всем привет! Вопрос не совсем по теме, но рядом- делал кто-то собственную кнопку авторизации через Telegram? Там iframe с загрузкой картинки с telegram.org и у меня не получилось (наверно в силу ограниченности моих js скилов). Нашел пост на https://stackoverflow.com/questions/56347902/telegram-authorization-without-default-button но он не помог...
Munoon Автор
Вот, сделал подключение бота с помощью Telegram Login Widget.
github.com/Munoon/Spring-Boot-Telegram-MFA-example/commit/ac2eb2ec60168d9c29ba8df71d453a5caa9d00ba
Когда начал писать, по началу кнопка телеграма тоже не грузилась. Открыл консоль бразер и понял в чём проблема: «Content Security Policy directive». При добавлении доменов через телеграм бота @BotFather, он не разрешает использовать «localhost». Решил использовать локальный IP. Всё работает, но только если использовать 80 порт. Подробнее тут: stackoverflow.com/a/58016947
gkislin
Вопрос о том, как его поменять… с тем, что telegram предлагает все ок, но он мне не подходит, тк кроме телеграма у меня еще несколько провайдеров