connect.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import time
  2. from dataclasses import dataclass
  3. from typing import Optional
  4. from urllib.parse import urlparse
  5. from otppy import OTP
  6. from selenium import webdriver
  7. from selenium.common.exceptions import ElementClickInterceptedException
  8. from selenium.common.exceptions import NoSuchElementException
  9. from selenium.common.exceptions import StaleElementReferenceException
  10. from selenium.common.exceptions import TimeoutException
  11. from selenium.webdriver.common.by import By
  12. from selenium.webdriver.firefox.options import Options as FirefoxOptions
  13. from selenium.webdriver.support import expected_conditions as EC
  14. from selenium.webdriver.support.ui import WebDriverWait
  15. USERNAME_INPUT_NAME = "loginfmt"
  16. PASSWORD_INPUT_NAME = "passwd"
  17. MFA_INPUT_NAME = "otc"
  18. CONTINUE_BUTTON_ID = "idSIButton9"
  19. MFA_CONTINUE_BUTTON_ID = "idSubmit_SAOTCC_Continue"
  20. MFA_ERROR_TEXT_ID = "idSpan_SAOTCC_Error_OTC"
  21. VPN_INSTALL_PAGE_EXCLUSIVE_ELEMENT_ID = "provisioning_action_label"
  22. MAX_RETRY_DOMAIN_CHECK = 16
  23. MFA_MAX_RETRY_COUNT = 3
  24. ELEMENT_CHECK_DELAY = 0.5
  25. DOMAIN_CHECK_DELAY = 0.5
  26. @dataclass
  27. class VPNCookie:
  28. domain: str
  29. cookie: str
  30. def login(
  31. username: str,
  32. password: str,
  33. mfa_secret: Optional[str] = None,
  34. vpn_site: str = "https://vpn.fhnw.ch",
  35. headless: bool = True,
  36. ) -> VPNCookie:
  37. options = FirefoxOptions()
  38. if headless:
  39. options.add_argument("--headless")
  40. driver = webdriver.Firefox(options=options)
  41. driver.get(vpn_site)
  42. retries = MAX_RETRY_DOMAIN_CHECK
  43. while not _is_on_ms_login_page(driver) and retries > 0:
  44. retries -= 1
  45. time.sleep(DOMAIN_CHECK_DELAY)
  46. if retries < 0:
  47. raise ValueError("We never reached the MS login page!")
  48. _fill_login(driver, username, password)
  49. _fill_mfa(driver, mfa_secret)
  50. _confirm_stay_signed_in(driver)
  51. _is_on_install_page(driver)
  52. return _get_webvpn_cookie(driver)
  53. def _fill_login(driver: webdriver.Firefox, username: str, password: str) -> None:
  54. _check_on_ms_login_page(driver)
  55. if not _find_element(driver, By.NAME, USERNAME_INPUT_NAME):
  56. raise ValueError("Could not find login page!")
  57. driver.find_element(By.NAME, USERNAME_INPUT_NAME).send_keys(username)
  58. driver.find_element(By.NAME, PASSWORD_INPUT_NAME).send_keys(password)
  59. click_continue(driver) # Confirm username
  60. click_continue(driver) # Confirm password
  61. def _fill_mfa(driver: webdriver.Firefox, mfa_secret: Optional[str]) -> None:
  62. _check_on_ms_login_page(driver)
  63. # Check if we have the MFA page
  64. if driver.current_url.endswith("/login") and mfa_secret is None:
  65. raise ValueError("You need to supply your MFA secret to log in!")
  66. # Check if we have an MFA code to enter
  67. if mfa_secret is None:
  68. return
  69. for _ in range(MFA_MAX_RETRY_COUNT):
  70. if not _find_element(driver, By.NAME, MFA_INPUT_NAME):
  71. time.sleep(ELEMENT_CHECK_DELAY)
  72. continue
  73. mfa_code = get_mfa_code(mfa_secret)
  74. driver.find_element(By.NAME, MFA_INPUT_NAME).send_keys(mfa_code)
  75. click_continue(driver, MFA_CONTINUE_BUTTON_ID) # Confirm otp
  76. if not driver.current_url.endswith("/login"):
  77. # We have moved on
  78. break
  79. # Check for any errors
  80. if not _find_element(driver, By.ID, MFA_ERROR_TEXT_ID, 2):
  81. # Did not find an error
  82. break
  83. def _confirm_stay_signed_in(driver: webdriver.Firefox) -> bool:
  84. if not _is_on_ms_login_page(driver):
  85. return False
  86. if not driver.current_url.endswith("/common/SAS/ProcessAuth"):
  87. return False
  88. click_continue(driver) # Confirm stay signed in
  89. return True
  90. def _find_element(driver: webdriver.Firefox, by: str, item: str, wait: int = 8) -> bool:
  91. try:
  92. w = WebDriverWait(driver, wait)
  93. w.until(EC.presence_of_element_located((by, item)))
  94. return True
  95. except TimeoutException:
  96. return False
  97. def _is_on_install_page(driver: webdriver.Firefox) -> None:
  98. if not _find_element(driver, By.ID, VPN_INSTALL_PAGE_EXCLUSIVE_ELEMENT_ID):
  99. raise ValueError("Could not find install page!")
  100. def _get_webvpn_cookie(driver: webdriver.Firefox) -> VPNCookie:
  101. webvpn_cookie = driver.get_cookie("webvpn")
  102. driver.close()
  103. if webvpn_cookie is None:
  104. raise ValueError(
  105. "Failed to find the webvpn cookie. Maybe the authentication has failed?"
  106. )
  107. webvpn_domain = webvpn_cookie["domain"]
  108. webvpn_value = webvpn_cookie["value"]
  109. return VPNCookie(domain=webvpn_domain, cookie=webvpn_value)
  110. def _check_on_ms_login_page(driver: webdriver.Firefox) -> None:
  111. if not _is_on_ms_login_page(driver):
  112. raise ValueError("We should still be on the MS login page but we aren't!")
  113. def _is_on_ms_login_page(driver: webdriver.Firefox) -> bool:
  114. return urlparse(driver.current_url).netloc == "login.microsoftonline.com"
  115. def click_continue(driver: webdriver.Firefox, btn_id: str = CONTINUE_BUTTON_ID) -> bool:
  116. """Returns true if still on login page, false if not"""
  117. for _ in range(16):
  118. try:
  119. driver.find_element(By.ID, btn_id).click()
  120. return True
  121. except (ElementClickInterceptedException, StaleElementReferenceException):
  122. pass
  123. except NoSuchElementException:
  124. pass
  125. time.sleep(ELEMENT_CHECK_DELAY)
  126. return False
  127. def get_mfa_code(secret: str) -> str:
  128. otp = OTP.fromb32(secret)
  129. code: str = otp.TOTP()[0]
  130. return code