diff --git a/e2e/test_flows_otp.py b/e2e/test_flows_otp.py new file mode 100644 index 000000000..0a9c955b7 --- /dev/null +++ b/e2e/test_flows_otp.py @@ -0,0 +1,141 @@ +"""test flow with otp stages""" +from base64 import b32decode +from sys import platform +from time import sleep +from unittest.case import skipUnless +from urllib.parse import parse_qs, urlparse + +from django_otp.oath import TOTP +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from e2e.utils import USER, SeleniumTestCase +from passbook.flows.models import Flow, FlowStageBinding +from passbook.stages.otp_validate.models import OTPValidateStage + + +@skipUnless(platform.startswith("linux"), "requires local docker") +class TestFlowsOTP(SeleniumTestCase): + """test flow with otp stages""" + + def test_otp_validate(self): + """test flow with otp stages""" + sleep(1) + # Setup TOTP Device + user = USER() + device = TOTPDevice.objects.create(user=user, confirmed=True, digits=6) + + flow: Flow = Flow.objects.get(slug="default-authentication-flow") + # Move the user_login stage to order 3 + FlowStageBinding.objects.filter(target=flow, order=2).update(order=3) + FlowStageBinding.objects.create( + target=flow, order=2, stage=OTPValidateStage.objects.create() + ) + + self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + + # Get expected token + totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift) + self.driver.find_element(By.ID, "id_code").send_keys(totp.token()) + self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, + USER().username, + ) + + def test_otp_totp_setup(self): + """test TOTP Setup stage""" + flow: Flow = Flow.objects.get(slug="default-authentication-flow") + + self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, + USER().username, + ) + + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-page__header").click() + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click() + self.wait_for_url(self.url("passbook_core:user-settings")) + + self.driver.find_element(By.LINK_TEXT, "Time-based OTP").click() + + # Remember the current URL as we should end up back here + destination_url = self.driver.current_url + + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-card__body a.pf-c-button" + ).click() + + otp_uri = self.driver.find_element( + By.CSS_SELECTOR, "#flow-body > div > form > div:nth-child(3) > div" + ).get_attribute("aria-label") + + # Parse the OTP URI, extract the secret and get the next token + otp_args = urlparse(otp_uri) + self.assertEqual(otp_args.scheme, "otpauth") + otp_qs = parse_qs(otp_args.query) + secret_key = b32decode(otp_qs["secret"][0]) + + totp = TOTP(secret_key) + + self.driver.find_element(By.ID, "id_code").send_keys(totp.token()) + self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER) + + self.wait_for_url(destination_url) + sleep(1) + + self.assertTrue(TOTPDevice.objects.filter(user=USER(), confirmed=True).exists()) + + def test_otp_static_setup(self): + """test Static OTP Setup stage""" + flow: Flow = Flow.objects.get(slug="default-authentication-flow") + + self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, + USER().username, + ) + + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-page__header").click() + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click() + self.wait_for_url(self.url("passbook_core:user-settings")) + + self.driver.find_element(By.LINK_TEXT, "Static OTP").click() + + # Remember the current URL as we should end up back here + destination_url = self.driver.current_url + + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-card__body a.pf-c-button" + ).click() + token = self.driver.find_element( + By.CSS_SELECTOR, ".pb-otp-tokens li:nth-child(1)" + ).text + + self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() + + self.wait_for_url(destination_url) + sleep(1) + + self.assertTrue( + StaticDevice.objects.filter(user=USER(), confirmed=True).exists() + ) + device = StaticDevice.objects.filter(user=USER(), confirmed=True).first() + self.assertTrue(StaticToken.objects.filter(token=token, device=device).exists()) diff --git a/e2e/test_flows_stage_setup.py b/e2e/test_flows_stage_setup.py index be8088520..6dd428be8 100644 --- a/e2e/test_flows_stage_setup.py +++ b/e2e/test_flows_stage_setup.py @@ -20,7 +20,7 @@ class TestFlowsStageSetup(SeleniumTestCase): """test password change flow""" # Ensure that password stage has change_flow set flow = Flow.objects.get( - slug="default-password-change", designation=FlowDesignation.STAGE_SETUP, + slug="default-password-change", designation=FlowDesignation.STAGE_CONFIGURATION, ) stages = PasswordStage.objects.filter(name="default-authentication-password") diff --git a/e2e/test_provider_oauth2_github.py b/e2e/test_provider_oauth2_github.py index f2a60a3d0..196fd11bd 100644 --- a/e2e/test_provider_oauth2_github.py +++ b/e2e/test_provider_oauth2_github.py @@ -1,5 +1,6 @@ """test OAuth Provider flow""" from sys import platform +from time import sleep from typing import Any, Dict, Optional from unittest.case import skipUnless @@ -159,6 +160,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): ), ).click() + sleep(1) + self.wait_for_url("http://localhost:3000/?orgId=1") self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( diff --git a/e2e/utils.py b/e2e/utils.py index f48d3aaa1..151791b5e 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,5 +1,4 @@ """passbook e2e testing utilities""" -from functools import lru_cache from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction @@ -23,7 +22,6 @@ from structlog import get_logger from passbook.core.models import User -@lru_cache # pylint: disable=invalid-name def USER() -> User: # noqa """Cached function that always returns pbadmin""" diff --git a/swagger.yaml b/swagger.yaml index 8debe8ca3..61c9c40a3 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -7392,6 +7392,13 @@ definitions: title: Name type: string minLength: 1 + configure_flow: + title: Configure flow + description: Flow used by an authenticated user to configure this Stage. If + empty, user will not be able to configure this stage. + type: string + format: uuid + x-nullable: true token_count: title: Token count type: integer