connect.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import logging
  2. import time
  3. from dataclasses import dataclass
  4. from typing import Optional
  5. from urllib.parse import urlparse
  6. from otppy import OTP
  7. from selenium import webdriver
  8. from selenium.common.exceptions import ElementClickInterceptedException
  9. from selenium.common.exceptions import ElementNotInteractableException
  10. from selenium.common.exceptions import NoSuchElementException
  11. from selenium.common.exceptions import StaleElementReferenceException
  12. from selenium.common.exceptions import TimeoutException
  13. from selenium.webdriver.common.by import By
  14. from selenium.webdriver.firefox.options import Options as FirefoxOptions
  15. from selenium.webdriver.support import expected_conditions as EC
  16. from selenium.webdriver.support.ui import WebDriverWait
  17. USERNAME_INPUT_NAME = "loginfmt"
  18. PASSWORD_INPUT_NAME = "passwd"
  19. MFA_INPUT_NAME = "otc"
  20. CONTINUE_BUTTON_ID = "idSIButton9"
  21. MFA_CONTINUE_BUTTON_ID = "idSubmit_SAOTCC_Continue"
  22. MFA_ERROR_TEXT_ID = "idSpan_SAOTCC_Error_OTC"
  23. VPN_INSTALL_PAGE_EXCLUSIVE_ELEMENT_ID = "provisioning_action_label"
  24. MAX_RETRY_DOMAIN_CHECK = 16
  25. MFA_MAX_RETRY_COUNT = 3
  26. ELEMENT_CHECK_DELAY = 0.5
  27. DOMAIN_CHECK_DELAY = 0.5
  28. LOGGER = logging.getLogger("OCMA")
  29. LOGGER.setLevel(logging.INFO)
  30. @dataclass
  31. class VPNCookie:
  32. domain: str
  33. cookie: str
  34. def login(
  35. username: str,
  36. password: str,
  37. mfa_secret: Optional[str] = None,
  38. vpn_site: str = "https://vpn.fhnw.ch",
  39. headless: bool = True,
  40. log_messages: bool = False,
  41. ) -> VPNCookie:
  42. if log_messages:
  43. formatter = logging.Formatter(
  44. "%(asctime)s: %(levelname)s - %(name)s - %(message)s"
  45. )
  46. fh_s = logging.StreamHandler()
  47. fh_s.setLevel(logging.INFO)
  48. fh_s.setFormatter(formatter)
  49. LOGGER.addHandler(fh_s)
  50. LOGGER.info("Starting")
  51. options = FirefoxOptions()
  52. if headless:
  53. LOGGER.info("Running in headless mode")
  54. options.add_argument("--headless")
  55. driver = webdriver.Firefox(options=options)
  56. driver.get(vpn_site)
  57. retries = MAX_RETRY_DOMAIN_CHECK
  58. while not _is_on_ms_login_page(driver) and retries > 0:
  59. retries -= 1
  60. time.sleep(DOMAIN_CHECK_DELAY)
  61. if retries < 0:
  62. raise ValueError(
  63. f"We never reached the MS login page! Currently on {driver.current_url}"
  64. )
  65. _fill_login(driver, username, password)
  66. _fill_mfa(driver, mfa_secret)
  67. _confirm_stay_signed_in(driver)
  68. _is_on_install_page(driver)
  69. return _get_webvpn_cookie(driver)
  70. def _fill_login(driver: webdriver.Firefox, username: str, password: str) -> None:
  71. LOGGER.info("Filling in login information")
  72. _check_on_ms_login_page(driver)
  73. if not _find_element(driver, By.NAME, USERNAME_INPUT_NAME):
  74. raise ValueError("Could not find login page!")
  75. if not _element_interactable(driver, By.NAME, USERNAME_INPUT_NAME):
  76. raise ValueError("Could not find username field!")
  77. driver.find_element(By.NAME, USERNAME_INPUT_NAME).send_keys(username)
  78. click_continue(driver)
  79. if not _element_interactable(driver, By.NAME, PASSWORD_INPUT_NAME):
  80. raise ValueError("Could not find password input!")
  81. driver.find_element(By.NAME, PASSWORD_INPUT_NAME).send_keys(password)
  82. click_continue(driver)
  83. on_login_form = lambda: "saml2" in driver.current_url
  84. retries = MAX_RETRY_DOMAIN_CHECK
  85. while on_login_form() and retries > 0:
  86. LOGGER.info(
  87. f"Login information may not have yet been confirmed. Checking again (Try {MAX_RETRY_DOMAIN_CHECK - retries + 1} of {MAX_RETRY_DOMAIN_CHECK})"
  88. )
  89. time.sleep(ELEMENT_CHECK_DELAY)
  90. if on_login_form():
  91. LOGGER.info("Login information has not yet been confirmed. Trying again")
  92. click_continue(driver) # Confirm password
  93. retries -= 1
  94. if retries < 0:
  95. raise ValueError("Failed to confirm login form!")
  96. LOGGER.info("Login information filled out")
  97. def _fill_mfa(driver: webdriver.Firefox, mfa_secret: Optional[str]) -> None:
  98. LOGGER.info("Checking if we can fill in MFA information")
  99. _check_on_ms_login_page(driver)
  100. # Check if we have the MFA page
  101. if driver.current_url.endswith("/login") and mfa_secret is None:
  102. raise ValueError("You need to supply your MFA secret to log in!")
  103. # Check if we have an MFA code to enter
  104. if mfa_secret is None:
  105. LOGGER.info("Filling in login information")
  106. return
  107. for i in range(MFA_MAX_RETRY_COUNT):
  108. LOGGER.info(
  109. f"Filling in MFA information (Try {i + 1} of {MFA_MAX_RETRY_COUNT})"
  110. )
  111. if not _find_element(driver, By.NAME, MFA_INPUT_NAME):
  112. time.sleep(ELEMENT_CHECK_DELAY)
  113. continue
  114. mfa_code = get_mfa_code(mfa_secret)
  115. driver.find_element(By.NAME, MFA_INPUT_NAME).send_keys(mfa_code)
  116. click_continue(driver, MFA_CONTINUE_BUTTON_ID) # Confirm otp
  117. LOGGER.info(f"Confirmed MFA code")
  118. if not driver.current_url.endswith("/login"):
  119. # We have moved on
  120. LOGGER.info(f"We have moved away from the MFA page")
  121. break
  122. # Check for any errors
  123. if not _find_element(driver, By.ID, MFA_ERROR_TEXT_ID, 2):
  124. # Did not find an error
  125. LOGGER.info(f"MFA seems to have been accepted, no errors")
  126. break
  127. def _confirm_stay_signed_in(driver: webdriver.Firefox) -> bool:
  128. LOGGER.info(f"Cheking if we should confirm if we should stay signed in")
  129. if not _is_on_ms_login_page(driver):
  130. LOGGER.info(f"Already logged in, can't confirm 'stay signed in'")
  131. return False
  132. if not driver.current_url.endswith("/common/SAS/ProcessAuth"):
  133. LOGGER.info(
  134. f"We have reached a dead end. We have completed the login, but not on the 'stay signed in' page"
  135. )
  136. return False
  137. click_continue(driver) # Confirm stay signed in
  138. LOGGER.info(f"Confirmed to 'stay signed in'")
  139. return True
  140. def _find_element(driver: webdriver.Firefox, by: str, item: str, wait: int = 8) -> bool:
  141. try:
  142. w = WebDriverWait(driver, wait)
  143. w.until(EC.presence_of_element_located((by, item)))
  144. return True
  145. except TimeoutException:
  146. return False
  147. def _element_interactable(
  148. driver: webdriver.Firefox, by: str, item: str, wait: int = 8
  149. ) -> bool:
  150. try:
  151. w = WebDriverWait(driver, wait)
  152. w.until(EC.element_to_be_clickable((by, item)))
  153. return True
  154. except ElementNotInteractableException:
  155. return False
  156. def _is_on_install_page(driver: webdriver.Firefox) -> None:
  157. if not _find_element(driver, By.ID, VPN_INSTALL_PAGE_EXCLUSIVE_ELEMENT_ID):
  158. raise ValueError("Could not find install page!")
  159. def _get_webvpn_cookie(driver: webdriver.Firefox) -> VPNCookie:
  160. webvpn_cookie = driver.get_cookie("webvpn")
  161. driver.close()
  162. if webvpn_cookie is None:
  163. raise ValueError(
  164. "Failed to find the webvpn cookie. Maybe the authentication has failed?"
  165. )
  166. webvpn_domain = webvpn_cookie["domain"]
  167. webvpn_value = webvpn_cookie["value"]
  168. return VPNCookie(domain=webvpn_domain, cookie=webvpn_value)
  169. def _check_on_ms_login_page(driver: webdriver.Firefox) -> None:
  170. if not _is_on_ms_login_page(driver):
  171. raise ValueError("We should still be on the MS login page but we aren't!")
  172. def _is_on_ms_login_page(driver: webdriver.Firefox) -> bool:
  173. return urlparse(driver.current_url).netloc == "login.microsoftonline.com"
  174. def click_continue(driver: webdriver.Firefox, btn_id: str = CONTINUE_BUTTON_ID) -> bool:
  175. """Returns true if still on login page, false if not"""
  176. for _ in range(16):
  177. try:
  178. driver.find_element(By.ID, btn_id).click()
  179. return True
  180. except (ElementClickInterceptedException, StaleElementReferenceException):
  181. pass
  182. except NoSuchElementException:
  183. pass
  184. time.sleep(ELEMENT_CHECK_DELAY)
  185. return False
  186. def get_mfa_code(secret: str) -> str:
  187. otp = OTP.fromb32(secret)
  188. code: str = otp.TOTP()[0]
  189. return code