Представим себе ситуацию: аналитики компании Foobar Inc. провели тщательное исследование конъюнктуры рынка и бизнес-процессов компании и пришли к выводу, что для оптимизации издержек и многократного увеличения прибыли Foobar кровь из носу требуется Telegram-бот компаньон, способный подбодрить сотрудников в трудную минуту.
Естественно, Foobar не может позволить, чтобы коварные конкуренты воспользовались их ноу-хау, просто добавив их бота себе в контакты. Поэтому требуется, чтобы бот разговаривал только с сотрудниками Foobar, прошедшими аутентификацию в корпоративной системе единого входа (SSO) на основе OpenId Connect.
OpenId Connect (OIDC) — это протокол аутентификации, основанный на семействе спецификаций OAuth 2.0. В нем процесс аутентификации может проходить по различным сценариям, называемым потоками (flow), и включает в себя три стороны:
Получаемая клиентом в результате аутентификации информация о пользователе представляется в виде JWT-токенов (JSON Web Token). Не будем сейчас углубляться в терминологию и детали спецификации OIDC, поскольку и без того есть немало статей, в том числе и на Хабре, позволяющих составить представление о базовых принципах работы OIDC.
Для решения поставленной задачи, помимо реализации основной функциональности бота, нам необходимо реализовать механизм авторизации: при получении каждого нового сообщения от пользователя необходимо проверять наличие и актуальность связки между данным Telegram-пользователем и учеткой в системе SSO. Неавторизованные пользователи при этом должны направляться на страницу аутентификации.
Для начала нам необходимо определиться с тем, какой поток аутентификации мы будем использовать. В спецификации OIDC описано несколько возможных сценариев аутентификации пользователей:
Поскольку наш бот — это серверное приложение с возможностью доступа по HTTP, нам подойдет поток с кодом авторизации.
Схема взаимодействия будет выглядеть следующим образом:
В описанном сценарии все хорошо, кроме того, что на последнем шаге, когда бот получил токен доступа и ID токен, у него уже нет информации о том, какой Telegram-пользователь инициировал всю цепочку действий.
Здесь самое время вспомнить про параметр state запроса аутентификации, который рекомендуется использовать для предотвращения атак межсайтовой подделки запросов (XSRF). Значение state формируется на стороне бота на шаге 2 и добавляется в URL в виде параметра, затем сервер авторизации на шаге 5 в неизменном виде передает это значение в callback URL бота. Таким образом, если мы на шаге 2 свяжем сгенерированный state с id Telegram-пользователя, то на шаге 7 по значению state сможем сопоставить id Telegram-пользователя с полученными токенами. Бинго!
Итак, перейдем к реализации, вооружившись следующими инструментами:
Для начала посмотрим на реализацию бота:
@Component
public class Bot extends TelegramLongPollingBot {
private final OidcService oidcService;
// ...
// Метод, который вызывается при получении ботом новых сообщений,
// inline-запросов и прочих обновлений.
@Override
public void onUpdateReceived(Update update) {
if (!update.hasMessage()) {
log.debug("Update has no message. Skip processing.");
return;
}
// Id Telegram-пользователя.
var userId = update.getMessage().getFrom().getId();
var chatId = update.getMessage().getChatId();
// Запрашиваем UserInfo (структуру с информацией о пользователе,
// полученной от сервера авторизации) по id Telegram-пользователя.
oidcService.findUserInfo(userId).ifPresentOrElse(
userInfo -> greet(userInfo, chatId),
() -> askForLogin(userId, chatId));
}
private void greet(UserInfo userInfo, Long chatId) {
// Здесь могло быть обращение к смежному сервису с использованием
// токена доступа. При этом с точки зрения смежного сервиса обращение
// бы выполнялось от имени пользователя, приславшего боту сообщение.
var username = userInfo.getPreferredUsername();
var message = String.format(
"Hello, <b>%s</b>!\nYou are the best! Have a nice day!",
username);
sendHtmlMessage(message, chatId);
}
private void askForLogin(Integer userId, Long chatId) {
// Формируем URL для аутентификации пользователя
// (см. шаг 2 на схеме взаимодействия).
var url = oidcService.getAuthUrl(userId);
var message = String.format("Please, <a href=\"%s\">log in</a>.", url);
sendHtmlMessage(message, chatId);
}
// ...
}
Далее — определение метода поиска UserInfo по id Telegram-пользователя и метода формирования URL для аутентификации пользователя:
@Service
public class OidcService {
// OAuth20Service — класс из ScribeJava.
// Через него, собственно, и происходит все общение с сервером авторизации.
private final OAuth20Service oAuthService;
// UserTrackerStorage — хранилище трекеров пользователей
// (связок state -> id Telegram-пользователя).
private final UserTrackerStorage userTrackers;
// TokenStorage — "умное" хранилище токенов (токенов доступа, ID токенов и
// токенов обновления (refresh token)) в привязке к id Telegram-пользователя.
// Если срок действия запрашиваемого токена доступа истек, хранилище само
// обращается к серверу авторизации за свежим токеном доступа, предъявляя
// соответствующий токен обновления.
// При этом заметим, что поскольку бот не является типичным веб-приложением,
// взаимодействующим с пользователем через браузер, нам требуются особые
// токены обновления, которые бы позволили запрашивать новые токены доступа,
// даже когда пользователь не залогинен на сервере авторизации.
// Это называется offline_access. Ниже мы увидим, как этого добиться.
// Для краткости, реализацию TokenStorage в данной статье приводить не будем.
private final TokenStorage accessTokens;
public Optional<UserInfo> findUserInfo(Integer userId) {
return accessTokens.find(userId)
.map(UserInfo::of);
}
public String getAuthUrl(Integer userId) {
var state = UUID.randomUUID().toString();
userTrackers.put(state, userId);
return oAuthService.getAuthorizationUrl(state);
}
// ...
}
Класс, содержащий информацию о пользователе, получаемую от сервера авторизации:
public class UserInfo {
private final String subject;
private final String preferredUsername;
static UserInfo of(OpenIdOAuth2AccessToken token) {
// Декодируем ID токен и создаем на его основе UserInfo.
var jwt = JWT.decode(token.getOpenIdToken());
var subject = jwt.getSubject();
var preferredUsername = jwt.getClaim("preferred_username").asString();
return new UserInfo(subject, preferredUsername);
}
}
Создание и настройка OAuth20Service:
@Configuration
@EnableConfigurationProperties(OidcProperties.class)
class OidcAutoConfiguration {
@Bean
OAuth20Service oAuthService(OidcProperties properties) {
// Для создания OAuth20Service указываем id клиента,
// с которым он зарегистрирован на сервере авторизации, ...
return new ServiceBuilder(properties.getClientId())
// секрет клиента, ...
.apiSecret(properties.getClientSecret())
// а также запрашиваемые разрешения (scopes):
// openid — значение по умолчанию для OpenId Connect,
// offline_access — то самое разрешение для офлайн-доступа к
// обновлению токенов, о котором мы говорили ранее.
.defaultScope("openid offline_access")
// Задаем callback, на который сервер авторизации будет
// перенаправлять пользователя на шаге 5.
.callback(properties.getCallback())
.build(KeycloakApi.instance(properties.getBaseUrl(), properties.getRealm()));
}
}
Callback endpoint нашего бота:
@Component
@Path("/auth")
public class AuthEndpoint {
private final URI botUri;
private final OidcService oidcService;
// ...
@GET
@Produces("text/plain; charset=UTF-8")
public Response auth(
@QueryParam("state") String state,
@QueryParam("code") String code) {
// Запрашиваем у сервера токены в обмен на код авторизации
// (см. шаг 6 на схеме взаимодействия).
return oidcService.completeAuth(state, code)
// Если все ок, то редиректим обратно на чат с ботом.
.map(userInfo -> Response.temporaryRedirect(botUri).build())
// Если по указанному state не нашелся пользователь, либо если
// произошла ошибка в ходе обмена кода авторизации на токены,
// возвращаем HTTP-статус 500.
.orElseGet(() -> Response.serverError().entity("Cannot complete authentication").build());
}
}
Снова вернемся к OidcService, чтобы посмотреть на реализацию метода completeAuth:
@Service
public class OidcService {
private final OAuth20Service oAuthService;
private final UserTrackerStorage userTrackers;
private final TokenStorage accessTokens;
// ...
public Optional<UserInfo> completeAuth(String state, String code) {
// Ищем id Telegram-пользователя по полученному state.
return userTrackers.find(state)
// Запрашиваем токены и сохраняем их в привязке к id
// Telegram-пользователя.
.map(userId -> requestAndStoreToken(code, userId))
.map(UserInfo::of);
}
private OpenIdOAuth2AccessToken requestAndStoreToken(
String code,
Integer userId) {
var token = requestToken(code);
accessTokens.put(userId, token);
return token;
}
private OpenIdOAuth2AccessToken requestToken(String code) {
try {
return (OpenIdOAuth2AccessToken) oAuthService.getAccessToken(code);
} catch (IOException | InterruptedException | ExecutionException e) {
throw new RuntimeException("Cannot get access token", e);
}
}
}
Готово!
Поставленная задача решена, но за кадром остался еще ряд вопросов, которые необходимо решить, прежде, чем катить это в прод. Например, офлайн-токены необходимо держать в персистентном хранилище, это позволит не направлять пользователей на повторную аутентификацию после каждого перезапуска/обновления бота.
Эти вопросы уже решены за нас в более развесистых OAuth-клиентах вроде Spring Security и Google OAuth Client. Но для демонстрационных целей нам и так ок :)
Все исходники можно найти на GitHub.
К сожалению, не доступен сервер mySQL