From 8fa42206aa2cfd48965af649ab8b5e12f7f79f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=9A=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Tue, 21 Jul 2026 23:47:35 +0700 Subject: [PATCH] add AD 2FA group check to instruct Trueconf module --- instruct/handlers.py | 34 ++++++++++++++++++++- utils/ad_checker.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ utils/texts.py | 44 +++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 utils/ad_checker.py diff --git a/instruct/handlers.py b/instruct/handlers.py index 85ea36a..5f00ca8 100644 --- a/instruct/handlers.py +++ b/instruct/handlers.py @@ -3,6 +3,7 @@ from utils.texts import ( EMOJI_DIGITS, INSTRUCT_MAIN_MENU_TEXT, INSTRUCT_TRUECONF_TEXT, + INSTRUCT_TRUECONF_WITH_AD, INSTRUCT_EMAIL_TEXT, ) import logging @@ -20,6 +21,35 @@ logger = logging.getLogger(__name__) router = Router() +def _get_user_cn(user_id: str) -> str: + """ + Получить CN пользователя из AD по его user_id (email или логин). + user_id из TrueConf — это обычно email, например 'ds.krivochenko@sibcem.ru' + """ + from ldap3 import Server, Connection, ALL + from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE + from ldap3.utils.conv import escape_filter_chars + + try: + server = Server(AD_SERVER, get_info=ALL) + conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True) + safe_id = escape_filter_chars(user_id) + short_username = user_id.split('@')[0] if '@' in user_id else user_id + search_filter = ( + f"(&(objectClass=user)(|" + f"(mail={safe_id})" + f"(userPrincipalName={safe_id})" + f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')}))" + f"(sAMAccountName={escape_filter_chars(short_username)})))" + ) + conn.search(AD_BASE, search_filter, attributes=["cn", "sAMAccountName"]) + if conn.entries: + return str(conn.entries[0].cn) + except Exception as e: + logger.error(f"Ошибка получения CN из AD для {user_id}: {e}") + return user_id # fallback — используем user_id как CN + + @router.message() async def instruct_router_handler(msg: Message): user_id = msg.from_user.id @@ -55,7 +85,9 @@ async def instruct_router_handler(msg: Message): elif cmd == "1": log_menu_stats(user_id, "Инструкции", "Trueconf") set_state(user_id, "INSTRUCT_TRUECONF_VIEW") - await msg.answer(INSTRUCT_TRUECONF_TEXT, parse_mode="html") + cn = _get_user_cn(user_id) + text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn) + await msg.answer(text, parse_mode="html") elif cmd == "2": log_menu_stats(user_id, "Инструкции", "Почта") set_state(user_id, "INSTRUCT_EMAIL_VIEW") diff --git a/utils/ad_checker.py b/utils/ad_checker.py new file mode 100644 index 0000000..484c663 --- /dev/null +++ b/utils/ad_checker.py @@ -0,0 +1,73 @@ +# /opt/trueconf_bot/utils/ad_checker.py +# Проверка членства пользователя в группах Active Directory + + +from ldap3 import Server, Connection, ALL + + +def _get_ad_connection(): + """Создать соединение с AD.""" + from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE # noqa: E501 + server = Server(AD_SERVER, get_info=ALL) + return Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True), AD_BASE + + +def get_user_groups(cn: str) -> list[str] | None: + """ + Найти пользователя по CN и вернуть список групп. + Возвращает None если пользователь не найден. + """ + conn, ad_base = _get_ad_connection() + try: + conn.search( + search_base=ad_base, + search_filter=f"(cn={cn})", + attributes=["cn", "sAMAccountName", "memberOf"], + ) + if not conn.entries: + return None + entry = conn.entries[0] + return [str(g) for g in entry.memberOf] + except Exception as e: + print(f"❌ Ошибка AD при поиске {cn}: {e}") + return None + + +def check_group_membership(cn: str, group_pattern: str = "2FA") -> dict: + """ + Проверить членство пользователя в группе. + + Args: + cn: CN пользователя (например 'Krivochenko_Denis_Sergeevich') + group_pattern: подстрока для поиска в имени группы (по умолчанию '2FA') + + Returns: + dict с полями: + found: bool — найден ли пользователь + cn: str | None — CN пользователя + sam: str | None — sAMAccountName + in_group: bool — в группе ли + groups: list[str] — все группы + matching_groups: list[str] — группы с паттерном + """ + groups = get_user_groups(cn) + if groups is None: + return { + "found": False, + "cn": None, + "sam": None, + "in_group": False, + "groups": [], + "matching_groups": [], + } + + matching = [g for g in groups if group_pattern.upper() in g.upper()] + + return { + "found": True, + "cn": cn, + "sam": groups[0].split(",")[0].split("=")[-1], # грубо из DN + "in_group": len(matching) > 0, + "groups": groups, + "matching_groups": matching, + } diff --git a/utils/texts.py b/utils/texts.py index 2fe0b20..5f9bacf 100644 --- a/utils/texts.py +++ b/utils/texts.py @@ -586,14 +586,55 @@ def _instruct_main_menu(emojis: dict) -> str: f"{emojis['2']} — 📧 Почта\n\n" f"{emojis['0']} — В главное меню" ) - def _instruct_trueconf(emojis: dict) -> str: + """Статический текст Trueconf — без проверки группы.""" return ( "🖥 Инструкция по Trueconf\n\n" "Данная функция находится в разработке.\n\n" f"{emojis['9']} — Назад\n{emojis['0']} — В главное меню" ) + +def _instruct_trueconf_with_ad(emojis: dict, cn: str) -> str: + """ + Текст Trueconf с проверкой группы 2FA. + cn — CN пользователя из AD. + """ + from utils.ad_checker import check_group_membership + + result = check_group_membership(cn, "2FA") + + if not result["found"]: + return ( + f"🖥 Инструкция по Trueconf\n\n" + f"Пользователь {cn} не найден в Active Directory.\n\n" + f"{emojis['9']} — Назад\n{emojis['0']} — В главное меню" + ) + + if result["in_group"]: + body = ( + f"🖥 Инструкция по Trueconf\n\n" + f"✅ Доступ к Trueconf разрешён.\n\n" + f"У вас есть доступ к функциям Trueconf.\n\n" + f"Детали:\n" + f"• CN: {result['cn']}\n" + f"• Группы 2FA: {', '.join(result['matching_groups'])}\n\n" + f"{emojis['9']} — Назад\n{emojis['0']} — В главное меню" + ) + else: + body = ( + f"🖥 Инструкция по Trueconf\n\n" + f"❌ Доступ к Trueconf запрещён.\n\n" + f"У вас нет группы 2FA в Active Directory.\n\n" + f"Детали:\n" + f"• CN: {result['cn']}\n" + f"• Групп 2FA: 0\n\n" + f"{emojis['9']} — Назад\n{emojis['0']} — В главное меню" + ) + + return body + + def _instruct_email(emojis: dict) -> str: return ( "📧 Инструкция по Почте\n\n" @@ -605,4 +646,5 @@ def _instruct_email(emojis: dict) -> str: INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS) INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS) +INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad INSTRUCT_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)