Недавно прикрутил к своей платформе телеграм-ботов возможность регистрироваться и входить в пользовательскую панельку сайта (ну или личный кабинет, если хотите). То есть, у тех ботов, для которых пользователю нужны веб-функции в личном кабинете, появляются кнопки регистрации и логина. Ну а если у бота такой «админки» нет, то не появляются.
Далее немного расскажу, как это технически реализовано на Laravel (на котором написан Botopotamus.Ru).
У Телеграма есть и свой механизм авторизации, но он подразумевает ручную настройку каждого бота, который должен это уметь. А я в данном случае рассматриваю код, который работает для всех ботов разом и его не нужно через Телеграм настраивать. Пользователь просто жмёт «Зарегистрироваться» в боте, а потом жмёт «Войти» чтобы получить ссылку на автологин.
Регистрация
Честно говоря, довольно тривиальная. Бот спрашивает у юзера имейл, проводит валидацию и проверку на уникальность, и в случае успеха регистрирует юзера. Код рассматривать смысла нет, в нем больше вызовов API моей платформы ботопотамов, он мало что иллюстрирует.
Логин
А вот на логин можно посмотреть, так как эту реализацию временных логинов можно использовать где угодно. Для реализации мы будем использовать временные подписанные ссылки. Похожие ссылки генерируются движком при восстановлении пароля, но там они одноразовые, а мы используем просто временные. Их можно тоже сделать одноразовыми, но по мне достаточно того, что они действуют 5 минут.
Начнем, и для функции логина напишем
Тесты
Опорный тест для функции логина через бота выглядит примерно так:
/**
* @test
*/
public function user_issues_login_command_and_gets_a_message_with_login_button(): void
{
// создаем юзера, бота, указываем что бот умеет в регистрацию
$user = User::factory()->create(['telegram_username' => 'mr_phpunit', 'telegram_id' => '123456789']);
$bot = Bot::factory()->create([
'user_id' => $user->id,
]);
$bot->settings()->create([
'register' => TRUE,
]);
// эта функция создает сообщение, идентичное полученному от телеграма
// в этом сообщении юзер mr_phpunit вызывает команду /login
$update = $this->generateMessageUpdate('mr_phpunit', '', '/login');
// обрабатываем наше сообщение
$webhooksService = resolve(TelegramBotWebhooksInterface::class); // получаем сервис обработки сообщений
// Чтобы эта часть теста работала, в платформе написан небольшой стартап тестов с созданием мок-объектов на Prophecy
// Это нужно, посольку мы не хотим чтобы на каждый тест у нас отправлялись реальные сообщения в ТГ
// Для проверки отправки есть отдельные тесты интеграции, а во всех остальных случаях
// нам достаточно факта вызова определнных методов (которые проверены в тестах интеграции и работают)
// В данном случае, $this->api->reveal() возвращает мок-объект бота
$webhooksService->processUpdates($this->api->reveal(), $update);
// ожидаем что будет вызван метод sendMessage() где в тексте сообщения будет Please click the button below...
// а ссылка в кнопке Login будет содержать роут быстрого логина
$this->api->sendMessage(Argument::that(function ($params) use ($user) {
return $params['text'] === "Please click the button below to log in with your account. You will be automatically redirected to your dashboard." &&
strpos($params['reply_markup']['inline_keyboard'][0][0]['url'], route('bot.quick-login', ['user' => $user->id])) === 0;
}))->shouldHaveBeenCalled();
}
Тест может показаться не очень понятным, но обратите внимание, что он при этом достаточно короткий и закрывает историю получения ссылки для логина. Про использование Argument::that() и проверку текста сообщения напишу отдельную заметку.
А эти три тестика проверяют собственно работу подписанных ссылок с помощью тестового перехода по ним:
/**
* @test
* Проверяем, что пользователь может залогиниться с помощью подписанной временной ссылки
* на роут bot.quick-login
*/
public function user_can_log_in_with_bot_quick_login_link(): void
{
$user = User::factory()->create();
// генерируем ссылку
$link = URL::temporarySignedRoute(
'bot.quick-login',
now()->addMinutes(5),
['user' => $user->id]
);
// проходим по ней
$response = $this->get($link);
// ожидаем что мы залогинились как $user и попали на страницу /main
$this->assertAuthenticatedAs($user);
$response->assertRedirect('/main');
}
/**
* @test
* Проверяем, что если в подписанной ссылке подменить id юзера, это не поможет
* войти за этого юзера
*/
public function cannot_log_in_with_url_replaced_user_id(): void
{
$user = User::factory()->create();
$anotherUser = User::factory()->create();
$link = URL::temporarySignedRoute(
'bot.quick-login',
now()->addMinutes(5),
['user' => $anotherUser->id]
);
// подменяем id на другого юзера
$response = $this->get(str_replace('/login/' . $anotherUser->id, '/login/' . $user->id, $link));
// ожидаем что мы по-прежнему не залогинены и получили ошибку 401
$this->assertGuest();
$response->assertStatus(401);
}
/**
* @test
* Проверяем, что если ссылка не подписанная, то никакого логина не случится
*/
public function cannot_log_in_without_signature(): void
{
$user = User::factory()->create();
$link = route('bot.quick-login', ['user' => $user->id]);
// переходим по неподписанной ссылке
$response = $this->get($link);
// ожидаем что мы по-прежнему не залогинены и получили ошибку 401
$this->assertGuest();
$response->assertStatus(401);
}
Опять-таки, заметьте, что тесты довольно лаконичные. Сделать их можно очень быстро. Также обращаю внимание, что последние 2 теста могут показаться ненужными: ведь мы не должны тестировать функционал подписания ссылок, он уже достаточно протестирован в Laravel. Но в данном случае эти тесты подтверждают тот факт, что роут bot.quick-login действительно проверяет подпись у ссылки, и с неверной или отсутствующей подписью работать не будет.
Реализация
Сам код обработчика команды тоже достаточно простой. Мы попадаем в handleMain() после нажатия юзером на кнопочку, передающую в нашего бота команду /login. В методе мы создаем временную подписанную ссылку на роут быстрого входа для пользователя с заданным id. Пользователь резолвится сервисом чуть раньше (в конструкторе) по id из полученного сообщения о нажатии на кнопку. Ссылку мы генерируем на 5 минут, вряд ли пользователю будет нужно больше времени, а если и будет нужно — он сгенерирует еще одну.
Получив ссылку, мы делаем из неё инлайн-кнопку, подцепленную к сообщению, и отправляем пользователю. Теперь, когда он нажмет на кнопку, его залогинит.
class LoginCommand extends TelegramCommand {
const command_id = "/login";
const command_name = "Log in";
protected $user;
protected function handleMain(Api $bot, TelegramUserState $user_state) {
// создаем подписанную ссылку входа
$login_link = URL::temporarySignedRoute(
'bot.quick-login',
now()->addMinutes(5), // 5 минут хватит
['user' => $this->user->id],
);
// отправляем сообщение с кнопкой входа пользователю
$bot->sendMessage([
'chat_id' => $this->chat_id, // для удобства chat_id заранее вытаскивается конструктором родительского объекта
'text' => "Please click the button below to log in with your account. You will be automatically redirected to your dashboard.",
'reply_markup' => Keyboard::make()->inline()->setResizeKeyboard(TRUE)->setOneTimeKeyboard(TRUE)->row([
Keyboard::inlineButton([
'text' => 'Log in',
'url' => $login_link,
]),
]),
]);
}
// в конструкторе команды резолвим пользователя, чтобы знать сразу кого логинить
public function __construct(\Telegram\Bot\Objects\Update $update, \App\Services\TelegramUserServiceInterface $telegramUserService)
{
parent::__construct($update, $telegramUserService);
$this->user = $this->telegramUserService->resolveTelegramUser($this->update);
}
}
Роут bot.quick-login описан так:
Route::get('/bot/login/{user}', [App\Http\Controllers\OneTimeLoginController::class, 'login'])
->name('bot.quick-login');
Мы вызываем метод контроллера OneTimeLoginController::login($user). Он, в свою очередь, реализован вот так:
class OneTimeLoginController extends Controller
{
public function login(Request $request, User $user) {
// проверяем, верна ли подпись и выходим, если нет
if (!$request->hasValidSignature()) {
abort(401);
}
// если подпись верна, авторизуем пользователя и перенаправляем в его личный кабинет
Auth::login($user);
$request->session()->regenerate();
return redirect('/main');
}
}
Подробнее про подписанные роуты в Ларавеле можно прочитать здесь.

