В эпоху цифровых технологий безопасность играет ключевую роль в защите ваших данных и систем. Особенно это актуально для серверов и систем, доступ к которым осуществляется через SSH. Даже если вы используете сложные пароли и надежные методы шифрования, одной аутентификации может быть недостаточно для полноценной защиты вашего сервера от несанкционированного доступа.
Двухфакторная аутентификация (2FA) – это мощный инструмент, который значительно повышает уровень безопасности, требуя подтверждения вашей личности с помощью второго фактора. В этом контексте, двухфакторная аутентификация через Telegram является достаточно эффективным решением, которое можно легко интегрировать в процесс SSH-подключения.
Зачем это может пригодиться? Во-первых, это значительно усложняет жизнь потенциальным злоумышленникам. Даже если пароль окажется скомпрометирован, доступ к вашему серверу будет возможен только после подтверждения входа через Telegram, что практически исключает риск несанкционированного доступа. Во-вторых, Telegram предоставляет удобный интерфейс и высокий уровень безопасности для отправки уведомлений и запросов на подтверждение, что делает процесс аутентификации простым и доступным.
В этой статье мы шаг за шагом рассмотрим, как настроить двухфакторную авторизацию для SSH с использованием Telegram-бота. Разберем все необходимые шаги – от создания бота до интеграции с вашим сервером, чтобы вы могли обеспечить дополнительный уровень безопасности для вашего окружения.
Ее величество, настройка
Создание бота в Telegram и получение необходимых реквизитов:
Все давно описано, но для полноты картины:
Создаем бота через @BotFather. Для чего нужно ввести команду /newbot , далее нужно вести имя и ник бота. В результате нужно получить токен, для доступа к боту.
![Процесс создания бота и получения токена доступа для статьи: 7449414805:AAGuDLfYOeC1ylwooYkt1xEEbpGxRKOXc8I Процесс создания бота и получения токена доступа для статьи: 7449414805:AAGuDLfYOeC1ylwooYkt1xEEbpGxRKOXc8I](https://habrastorage.org/getpro/habr/upload_files/921/895/b1f/921895b1f934d28ab90df17625b51e27.png)
Далее необходимо запустить созданного бота через команду /start и что-то в него написать, например, "Привет, Хабр!"
![](https://habrastorage.org/getpro/habr/upload_files/5ea/a33/744/5eaa337448a52d059bc152eb45d7956b.png)
Сообщение нужно для того, чтобы впоследствии узнать ваш Chat ID. Сделать это можно при помощи следующей команды (не забываем указать токен вашего бота):
curl -s https://api.telegram.org/bot{BOT_TOKEN}/getUpdates | grep -o '"id":[0-9]*' | head -1 | awk -F: '{print $2}'
Пример того, как это выглядит:
![Стрелочкой указан результат, значение Chat_ID: 9414805 Стрелочкой указан результат, значение Chat_ID: 9414805](https://habrastorage.org/getpro/habr/upload_files/d07/d91/e6d/d07d91e6d18cc1916a3d462db10c3dfc.png)
На этом часть настройки Telegram бота заканчивается.
Настройка на машине с Linux
Все шаги выполняются на системе Linux Debian 12. Но адаптировать их под вашу точно не составит труда ;)
Для начала, необходимо установить Python, если вдруг его нет.
sudo apt update && sudo apt install -y python3 python3-pip
И несколько pip пакетов, которые нам пригодятся:
pip3 install python-telegram-bot aiofiles requests --break-system-packages
Создадим сам python-скрипт, которые реализуют логику двухфакторной аутентификации.
Не забудьте изменить TOKEN и CHAT_ID на ваши.
cat > telegram_auth.py <<EOF
import telegram
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
import sys
import os
import asyncio
from datetime import datetime
import requests # Для получения информации о городе и провайдере
import subprocess # Для выполнения системных команд
# Конфигурация
TOKEN = '7449414805:AAGuDLfYOeC1ylwooYkt1xEEbpGxRKOXc8I' # Токен вашего Telegram-бота
CHAT_ID = '9414805' # ID чата в Telegram, куда будут отправляться сообщения
IP_INFO_URL = 'http://ipinfo.io/{}/json' # URL для получения информации о IP-адресе (город, провайдер и т.д.)
# Создаем объект бота с использованием токена
bot = telegram.Bot(token=TOKEN)
# Словарь для хранения запросов на подтверждение
request_data = {}
def get_local_ip():
"""
Функция для получения локального IP-адреса машины, на которой выполняется скрипт.
Использует команду 'hostname -I' для получения IP-адресов и возвращает первый из них.
"""
try:
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
return result.stdout.strip().split()[0] # Возвращаем первый IP из списка
except Exception:
return 'Неизвестен' # Возвращаем 'Неизвестен' в случае ошибки
def get_hostname():
"""
Функция для получения имени хоста машины, на которой выполняется скрипт.
Использует команду 'hostname' для получения имени хоста.
"""
try:
result = subprocess.run(['hostname'], capture_output=True, text=True)
return result.stdout.strip() # Возвращаем имя хоста
except Exception:
return 'Неизвестен' # Возвращаем 'Неизвестен' в случае ошибки
async def send_telegram_message(username, remote_ip, request_id):
"""
Асинхронная функция для отправки сообщения в Telegram с информацией о попытке входа.
Сообщение включает время входа, IP-адрес, информацию о городе и провайдере,
локальный IP и имя хоста.
"""
# Получаем информацию о IP
ip_info = {}
try:
response = requests.get(IP_INFO_URL.format(remote_ip))
ip_info = response.json() # Преобразуем ответ в формат JSON
except Exception:
ip_info = {} # Если возникла ошибка, оставляем словарь пустым
# Извлекаем информацию из ответа
city = ip_info.get('city', 'Неизвестно')
provider = ip_info.get('org', 'Неизвестно')
login_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Текущее время в нужном формате
local_ip = get_local_ip() # Получаем локальный IP
hostname = get_hostname() # Получаем имя хоста
# Формируем текст сообщения
message = (f"? Login Time: {login_time}\n"
f"? Hostname: {hostname}\n"
f"? Remote IP: {remote_ip}\n"
f"? System IP: {local_ip}\n"
f"? Provider: {provider}\n"
f"?️ City: {city}\n"
f"? Username: {username}")
# Создаем кнопки для ответа (разрешить или запретить вход)
reply_markup = InlineKeyboardMarkup([
[InlineKeyboardButton("Разрешить", callback_data=f"allow_{request_id}"),
InlineKeyboardButton("Запретить", callback_data=f"deny_{request_id}")]
])
try:
await bot.send_message(chat_id=CHAT_ID, text=message, reply_markup=reply_markup)
except Exception:
pass # Игнорируем ошибки при отправке сообщения
async def main():
"""
Основная асинхронная функция, которая запускает процесс обработки входящих запросов.
"""
global request_data
username = os.getenv('PAM_USER') # Получаем имя пользователя из переменной окружения PAM_USER
remote_ip = os.getenv('PAM_RHOST') # Получаем IP-адрес удаленного хоста из переменной окружения PAM_RHOST
if not username or not remote_ip:
sys.exit(1) # Если данные отсутствуют, завершаем выполнение с кодом 1
# Создаем уникальный идентификатор запроса на основе текущего времени
request_id = str(int(datetime.now().timestamp()))
# Сохраняем информацию о запросе в словаре
request_data[request_id] = {'username': username, 'remote_ip': remote_ip, 'timestamp': datetime.now().isoformat()}
# Отправляем сообщение в Telegram с запросом на подтверждение входа
await send_telegram_message(username, remote_ip, request_id)
update_id = None # ID последнего обновления для бота
start_time = datetime.now() # Время начала обработки запросов
while True:
try:
# Проверяем, прошло ли более 60 секунд с начала обработки запросов
if (datetime.now() - start_time).total_seconds() > 60:
sys.exit(1) # Завершаем выполнение, если прошло больше 60 секунд
# Получаем обновления от бота
updates = await bot.get_updates(offset=update_id, timeout=10)
for update in updates:
update_id = update.update_id + 1 # Обновляем ID последнего обновления
if update.callback_query: # Проверяем, есть ли обратный вызов с кнопки
callback_data = update.callback_query.data # Извлекаем данные из обратного вызова
if callback_data.startswith('allow_') or callback_data.startswith('deny_'):
req_id = callback_data.split('_')[1] # Извлекаем ID запроса из данных обратного вызова
if req_id in request_data:
if callback_data.startswith('allow_'):
del request_data[req_id] # Удаляем обработанный запрос из словаря
sys.exit(0) # Разрешаем вход
elif callback_data.startswith('deny_'):
del request_data[req_id] # Удаляем обработанный запрос из словаря
sys.exit(1) # Запрещаем вход
except Exception:
pass # Игнорируем ошибки в процессе обработки
await asyncio.sleep(1) # Ожидаем перед следующим запросом
if __name__ == "__main__":
asyncio.run(main()) # Запускаем основную асинхронную функцию
EOF
Добавляем конфигурацию PAM для аутентификации через Telegram (не забываем изменить путь к файлу telegram_auth.py):
cat > /etc/pam.d/telegram-auth <<EOF
auth requisite pam_exec.so stdout /usr/bin/python3 /root/telegram_auth.py
EOF
Включаем аутентификацию через Telegram в SSH:
sed -i '/^auth\s.*pam_exec.so/d' /etc/pam.d/sshd && \
echo "auth include telegram-auth" >> /etc/pam.d/sshd
Перезапускаем SSH для применения изменений:
systemctl restart sshd
Проверка
Выполним подключение по SSH к хосту и введем пароль:
ssh root@192.168.50.77
![](https://habrastorage.org/getpro/habr/upload_files/112/a99/b84/112a99b84eb32bc8ae9d1c9a21dca85f.png)
В этот же момент прилетает сообщение в созданном Telegram-боте:
![](https://habrastorage.org/getpro/habr/upload_files/ad4/0b1/073/ad40b1073cddd2e20ff8eb93fd15489f.png)
Если доступ разрешаем, то авторизация происходит успешно:
![](https://habrastorage.org/getpro/habr/upload_files/e59/cbf/664/e59cbf6645840d5e0a8ef081ba3d7422.png)
Если запрещаем, то в доступе отказано.
![](https://habrastorage.org/getpro/habr/upload_files/bc6/ee3/13d/bc6ee313d076342b41ecf395cadcb0f3.png)
Как все развернуть одной командой (только реквизиты свои указать нужно):
bash -c '
apt update && apt install python3 python3-pip -y &&
pip3 install python-telegram-bot aiofiles requests --break-system-packages &&
cat <<EOF > /root/telegram_auth.py
import telegram
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
import sys
import os
import asyncio
from datetime import datetime
import requests # Для получения информации о городе и провайдере
import subprocess # Для выполнения системных команд
# Конфигурация
TOKEN = '7449414805:AAGuDLfYOeC1ylwooYkt1xEEbpGxRKOXc8I' # Токен вашего Telegram-бота
CHAT_ID = '9414805' # ID чата в Telegram, куда будут отправляться сообщения
IP_INFO_URL = 'http://ipinfo.io/{}/json' # URL для получения информации о IP-адресе (город, провайдер и т.д.)
# Создаем объект бота с использованием токена
bot = telegram.Bot(token=TOKEN)
# Словарь для хранения запросов на подтверждение
request_data = {}
def get_local_ip():
"""
Функция для получения локального IP-адреса машины, на которой выполняется скрипт.
Использует команду 'hostname -I' для получения IP-адресов и возвращает первый из них.
"""
try:
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
return result.stdout.strip().split()[0] # Возвращаем первый IP из списка
except Exception:
return 'Неизвестен' # Возвращаем 'Неизвестен' в случае ошибки
def get_hostname():
"""
Функция для получения имени хоста машины, на которой выполняется скрипт.
Использует команду 'hostname' для получения имени хоста.
"""
try:
result = subprocess.run(['hostname'], capture_output=True, text=True)
return result.stdout.strip() # Возвращаем имя хоста
except Exception:
return 'Неизвестен' # Возвращаем 'Неизвестен' в случае ошибки
async def send_telegram_message(username, remote_ip, request_id):
"""
Асинхронная функция для отправки сообщения в Telegram с информацией о попытке входа.
Сообщение включает время входа, IP-адрес, информацию о городе и провайдере,
локальный IP и имя хоста.
"""
# Получаем информацию о IP
ip_info = {}
try:
response = requests.get(IP_INFO_URL.format(remote_ip))
ip_info = response.json() # Преобразуем ответ в формат JSON
except Exception:
ip_info = {} # Если возникла ошибка, оставляем словарь пустым
# Извлекаем информацию из ответа
city = ip_info.get('city', 'Неизвестно')
provider = ip_info.get('org', 'Неизвестно')
login_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Текущее время в нужном формате
local_ip = get_local_ip() # Получаем локальный IP
hostname = get_hostname() # Получаем имя хоста
# Формируем текст сообщения
message = (f"? Login Time: {login_time}\n"
f"? Hostname: {hostname}\n"
f"? Remote IP: {remote_ip}\n"
f"? System IP: {local_ip}\n"
f"? Provider: {provider}\n"
f"?️ City: {city}\n"
f"? Username: {username}")
# Создаем кнопки для ответа (разрешить или запретить вход)
reply_markup = InlineKeyboardMarkup([
[InlineKeyboardButton("Разрешить", callback_data=f"allow_{request_id}"),
InlineKeyboardButton("Запретить", callback_data=f"deny_{request_id}")]
])
try:
await bot.send_message(chat_id=CHAT_ID, text=message, reply_markup=reply_markup)
except Exception:
pass # Игнорируем ошибки при отправке сообщения
async def main():
"""
Основная асинхронная функция, которая запускает процесс обработки входящих запросов.
"""
global request_data
username = os.getenv('PAM_USER') # Получаем имя пользователя из переменной окружения PAM_USER
remote_ip = os.getenv('PAM_RHOST') # Получаем IP-адрес удаленного хоста из переменной окружения PAM_RHOST
if not username or not remote_ip:
sys.exit(1) # Если данные отсутствуют, завершаем выполнение с кодом 1
# Создаем уникальный идентификатор запроса на основе текущего времени
request_id = str(int(datetime.now().timestamp()))
# Сохраняем информацию о запросе в словаре
request_data[request_id] = {'username': username, 'remote_ip': remote_ip, 'timestamp': datetime.now().isoformat()}
# Отправляем сообщение в Telegram с запросом на подтверждение входа
await send_telegram_message(username, remote_ip, request_id)
update_id = None # ID последнего обновления для бота
start_time = datetime.now() # Время начала обработки запросов
while True:
try:
# Проверяем, прошло ли более 60 секунд с начала обработки запросов
if (datetime.now() - start_time).total_seconds() > 60:
sys.exit(1) # Завершаем выполнение, если прошло больше 60 секунд
# Получаем обновления от бота
updates = await bot.get_updates(offset=update_id, timeout=10)
for update in updates:
update_id = update.update_id + 1 # Обновляем ID последнего обновления
if update.callback_query: # Проверяем, есть ли обратный вызов с кнопки
callback_data = update.callback_query.data # Извлекаем данные из обратного вызова
if callback_data.startswith('allow_') or callback_data.startswith('deny_'):
req_id = callback_data.split('_')[1] # Извлекаем ID запроса из данных обратного вызова
if req_id in request_data:
if callback_data.startswith('allow_'):
del request_data[req_id] # Удаляем обработанный запрос из словаря
sys.exit(0) # Разрешаем вход
elif callback_data.startswith('deny_'):
del request_data[req_id] # Удаляем обработанный запрос из словаря
sys.exit(1) # Запрещаем вход
except Exception:
pass # Игнорируем ошибки в процессе обработки
await asyncio.sleep(1) # Ожидаем перед следующим запросом
if __name__ == "__main__":
asyncio.run(main()) # Запускаем основную асинхронную функцию
EOF
cat <<EOF > /etc/pam.d/telegram-auth
auth requisite pam_exec.so stdout /usr/bin/python3 /root/telegram_auth.py
EOF
sed -i "/^auth\s.*pam_exec.so/d" /etc/pam.d/sshd
echo "auth include telegram-auth" >> /etc/pam.d/sshd &&
systemctl restart sshd &&
echo "Скрипт завершен. Аутентификация через Telegram настроена и SSH перезапущен."
'
На этом все :) Надеюсь, кому-то пригодится.
Комментарии (29)
MUTbKA98
27.08.2024 14:21Что такое "пароль на ssh", если календарь показывает 2024-й год уже?
ildarz
27.08.2024 14:21+4Это такой секрет доступа, который человек способен запомнить (а не хранить на каком-либо носителе).
zoto_ff
27.08.2024 14:21запоминать пароли плохо. пароль, придуманный человеком, можно угадать (неважно с какой вероятностью). плюс с запомненным паролем будет желание использовать его в нескольких местах сразу, что тоже плохо
ildarz
27.08.2024 14:21+3Когда делается выбор между способами аутентификации, нужно не общеизвестные мантры повторять, а здраво оценивать потребности и риски в конкретном случае.
mstudiodad
27.08.2024 14:21Это такая штука, которая нужна, когда умирает единственный носитель ключа… Хотя здесь скорее пароль VNC
HomeMan
27.08.2024 14:21Судя по отчёту fail2ban за последние сутки, на мой сервер с логином ломились 1734 раза.
Авторизация по логину отключена.
Это мне надо будет 1734 раза нажать кнопку "Запретить"? Работать то когда?
CrazyHackGUT
27.08.2024 14:21+4Подобные способы реализации двухфакторной авторизации — это отстрел лица. Насквозь. Пуля почувствуется в момент когда телега решит прилечь.
Чем SSH-ключи не угодили?
ZimniY
27.08.2024 14:21У одного «стартапа» как-то прилегла авторизация из-за того, что сервис не смог отправить оповещение в слак о новом логине. Сделать асинхронный запрос — «зачем чинить то, что не сломалось?». Тестов у них, емнип, на момент моего общения с их представителем, тоже не было.
VenbergV
27.08.2024 14:21Возможно я не прав... но:
0. ssh и пароль в 2024 году нужно считать "днем открытых дверей".
1. По моим наблюдениям, с какого устройства подключаются на ssh, на нем же и TG запущен. Для удобства. Хотя бы в браузере. Контроль над TG будет получен одновременно с ключами.
2. Если TG ляжет (по любой причине), то контроль по ssh будет потерян.ildarz
27.08.2024 14:21ssh и пароль в 2024 году нужно считать "днем открытых дверей".
Только SSH или любой парольный доступ к любым сервисам?
AlexeyPolunin
27.08.2024 14:21Так вроде как пароль в 24 символа надо подбирать много тысяч лет. У меня есть штук 5 тестовых серверов где о ужас доступ по ssh у рута по паролю и стоит нотификация о подключении в телегу. За 4 года я не видел неавторизованных доступов. Как такое может быть?
Dorlas
27.08.2024 14:21Ошибка выжившего? :)
А вообще я везде по ключам хожу только и порты открываю себе Port Knock-ом.
Sigest
27.08.2024 14:21А можете указать на хороший гайд где описывается настройка уведомлений о подключениях через телегу?
SLenik
Ой зря вы реальный токен бота оставили и в картинках, и в текстах...
vlad_gatsenko Автор
Так его уже нет :)
MAXH0
Ой! Да уже всем без разницы. К чему утруждать полицию.. Месье комиссар одобряет