Merge branch 'master' into otp-rework

# Conflicts:
#	passbook/flows/models.py
#	passbook/stages/otp/models.py
#	swagger.yaml
This commit is contained in:
Jens Langhammer 2020-06-29 22:33:04 +02:00
commit 920858ff72
31 changed files with 899 additions and 615 deletions

30
Pipfile.lock generated
View file

@ -46,18 +46,18 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:8eeaa2d6374f02d6f0d6b8bee55838add9a4246de81b1402e59372296402f6c7", "sha256:2616351c98eec18d20a1d64b33355c86cd855ac96219d1b8428c9bfc590bde53",
"sha256:d1d93ed75f477e8910b8b074ae76e3189d1c3a3998ea679ab52fdbacb8b4f390" "sha256:7daad26a008c91dd7b82fde17d246d1fe6e4b3813426689ef8bac9017a277cfb"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.14.11" "version": "==1.14.12"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:64454a6dff9a3ced0dd75c0e69a8842aa663e0682d1dc5c8913fb76d354bfe3d", "sha256:45934d880378777cefeca727f369d1f5aebf6b254e9be58e7c77dd0b059338bb",
"sha256:715b41f3215214e75bf7b8e88bdbc38dc055eef761b37dbd559bac1a5becb3c2" "sha256:a94e0e2307f1b9fe3a84660842909cd2680b57a9fc9fb0c3a03b0afb2eadbe21"
], ],
"version": "==1.17.11" "version": "==1.17.12"
}, },
"celery": { "celery": {
"hashes": [ "hashes": [
@ -322,10 +322,10 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
], ],
"version": "==2.9" "version": "==2.10"
}, },
"inflection": { "inflection": {
"hashes": [ "hashes": [
@ -604,10 +604,10 @@
}, },
"pyparsing": { "pyparsing": {
"hashes": [ "hashes": [
"sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492", "sha256:1060635ca5ac864c2b7bc7b05a448df4e32d7d8c65e33cbe1514810d339672a2",
"sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a" "sha256:56a551039101858c9e189ac9e66e330a03fb7079e97ba6b50193643905f450ce"
], ],
"version": "==3.0.0a1" "version": "==3.0.0a2"
}, },
"pyrsistent": { "pyrsistent": {
"hashes": [ "hashes": [
@ -1044,10 +1044,10 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
], ],
"version": "==2.9" "version": "==2.10"
}, },
"isort": { "isort": {
"hashes": [ "hashes": [

View file

@ -286,6 +286,204 @@
], ],
"value": "foo@bar.baz" "value": "foo@bar.baz"
}] }]
}, {
"id": "1a3172e0-ac23-4781-9367-19afccee4f4a",
"name": "flows stage setup password",
"commands": [{
"id": "77784f77-d840-4b3d-a42f-7928f02fb7e1",
"comment": "",
"command": "open",
"target": "/flows/default-authentication-flow/?next=%2F",
"targets": [],
"value": ""
}, {
"id": "783aa9a6-81e5-49c6-8789-2f360a5750b1",
"comment": "",
"command": "setWindowSize",
"target": "1699x1417",
"targets": [],
"value": ""
}, {
"id": "cb0cd63e-30e9-4443-af59-5345fe26dc88",
"comment": "",
"command": "click",
"target": "id=id_uid_field",
"targets": [
["id=id_uid_field", "id"],
["name=uid_field", "name"],
["css=#id_uid_field", "css:finder"],
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
["xpath=//div/input", "xpath:position"]
],
"value": ""
}, {
"id": "8466ded1-c5f6-451c-b63f-0889da38503a",
"comment": "",
"command": "type",
"target": "id=id_uid_field",
"targets": [
["id=id_uid_field", "id"],
["name=uid_field", "name"],
["css=#id_uid_field", "css:finder"],
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
["xpath=//div/input", "xpath:position"]
],
"value": "pbadmin"
}, {
"id": "27383093-d01a-4416-8fc6-9caad4926cd3",
"comment": "",
"command": "sendKeys",
"target": "id=id_uid_field",
"targets": [
["id=id_uid_field", "id"],
["name=uid_field", "name"],
["css=#id_uid_field", "css:finder"],
["xpath=//input[@id='id_uid_field']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
["xpath=//div/input", "xpath:position"]
],
"value": "${KEY_ENTER}"
}, {
"id": "4602745a-0ebb-4425-a841-a1ed4899659d",
"comment": "",
"command": "type",
"target": "id=id_password",
"targets": [
["id=id_password", "id"],
["name=password", "name"],
["css=#id_password", "css:finder"],
["xpath=//input[@id='id_password']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
["xpath=//div[2]/input", "xpath:position"]
],
"value": "pbadmin"
}, {
"id": "d1ff4f81-d8f9-45dc-ad5d-f99b54c0cd18",
"comment": "",
"command": "sendKeys",
"target": "id=id_password",
"targets": [
["id=id_password", "id"],
["name=password", "name"],
["css=#id_password", "css:finder"],
["xpath=//input[@id='id_password']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
["xpath=//div[2]/input", "xpath:position"]
],
"value": "${KEY_ENTER}"
}, {
"id": "014c8f57-7ef2-469c-b700-efa94ba81b66",
"comment": "",
"command": "click",
"target": "css=.pf-c-page__header",
"targets": [
["css=.pf-c-page__header", "css:finder"],
["xpath=//div[@id='page-default-nav-example']/header", "xpath:idRelative"],
["xpath=//header", "xpath:position"]
],
"value": ""
}, {
"id": "14e86b6f-6add-4bcc-913a-42b1e7322c79",
"comment": "",
"command": "click",
"target": "linkText=pbadmin",
"targets": [
["linkText=pbadmin", "linkText"],
["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"],
["xpath=//a[contains(text(),'pbadmin')]", "xpath:link"],
["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"],
["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"],
["xpath=//div[2]/a", "xpath:position"],
["xpath=//a[contains(.,'pbadmin')]", "xpath:innerText"]
],
"value": ""
}, {
"id": "8280da13-632e-4cba-9e18-ecae0d57d052",
"comment": "",
"command": "click",
"target": "linkText=Change password",
"targets": [
["linkText=Change password", "linkText"],
["css=.pf-c-nav__section:nth-child(2) .pf-c-nav__link", "css:finder"],
["xpath=//a[contains(text(),'Change password')]", "xpath:link"],
["xpath=//nav[@id='page-default-nav-example-primary-nav']/section[2]/ul/li/a", "xpath:idRelative"],
["xpath=//a[contains(@href, '/-/user/stage/password/b929b529-e384-4409-8d40-ac4a195fcab2/change/?next=%2F-%2Fuser%2F')]", "xpath:href"],
["xpath=//section[2]/ul/li/a", "xpath:position"],
["xpath=//a[contains(.,'Change password')]", "xpath:innerText"]
],
"value": ""
}, {
"id": "716d7e0c-79dc-469b-a31f-dceaa0765e9c",
"comment": "",
"command": "click",
"target": "id=id_password",
"targets": [
["id=id_password", "id"],
["name=password", "name"],
["css=#id_password", "css:finder"],
["xpath=//input[@id='id_password']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
["xpath=//div/input", "xpath:position"]
],
"value": ""
}, {
"id": "77005d70-adf0-4add-8329-b092d43f829a",
"comment": "",
"command": "type",
"target": "id=id_password",
"targets": [
["id=id_password", "id"],
["name=password", "name"],
["css=#id_password", "css:finder"],
["xpath=//input[@id='id_password']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"],
["xpath=//div/input", "xpath:position"]
],
"value": "test"
}, {
"id": "965ca365-99f4-45d1-97c3-c944269341b9",
"comment": "",
"command": "click",
"target": "id=id_password_repeat",
"targets": [
["id=id_password_repeat", "id"],
["name=password_repeat", "name"],
["css=#id_password_repeat", "css:finder"],
["xpath=//input[@id='id_password_repeat']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
["xpath=//div[2]/input", "xpath:position"]
],
"value": ""
}, {
"id": "9b421468-c65e-4943-b6b1-1e80410a6b87",
"comment": "",
"command": "type",
"target": "id=id_password_repeat",
"targets": [
["id=id_password_repeat", "id"],
["name=password_repeat", "name"],
["css=#id_password_repeat", "css:finder"],
["xpath=//input[@id='id_password_repeat']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"],
["xpath=//div[2]/input", "xpath:position"]
],
"value": "test"
}, {
"id": "572c1400-a0f2-499f-808a-18c1f56bf13f",
"comment": "",
"command": "click",
"target": "css=.pf-c-button",
"targets": [
["css=.pf-c-button", "css:finder"],
["xpath=//button[@type='submit']", "xpath:attributes"],
["xpath=//main[@id='flow-body']/div/form/div[3]/button", "xpath:idRelative"],
["xpath=//button", "xpath:position"],
["xpath=//button[contains(.,'Continue')]", "xpath:innerText"]
],
"value": ""
}]
}], }],
"suites": [{ "suites": [{
"id": "495657fb-3f5e-4431-877c-4d0b248c0841", "id": "495657fb-3f5e-4431-877c-4d0b248c0841",

View file

@ -1,477 +0,0 @@
"""Test Enroll flow"""
from time import sleep
from django.test import override_settings
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from e2e.utils import USER, SeleniumTestCase
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.models import PolicyBinding
from passbook.stages.email.models import EmailStage, EmailTemplates
from passbook.stages.identification.models import IdentificationStage
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
from passbook.stages.user_login.models import UserLoginStage
from passbook.stages.user_write.models import UserWriteStage
class TestEnroll(SeleniumTestCase):
"""Test Enroll flow"""
def setUp(self):
super().setUp()
self.container = self.setup_client()
def setup_client(self) -> Container:
"""Setup test IdP container"""
client: DockerClient = from_env()
container = client.containers.run(
image="mailhog/mailhog",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
test=["CMD", "wget", "-s", "http://localhost:8025"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
# pylint: disable=too-many-statements
def setup_test_enroll_2_step(self):
"""Setup all required objects"""
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.driver.find_element(By.LINK_TEXT, "Administrate").click()
self.driver.find_element(By.LINK_TEXT, "Prompts").click()
# Create Password Prompt
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_field_key").send_keys("password")
self.driver.find_element(By.ID, "id_label").send_keys("Password")
dropdown = self.driver.find_element(By.ID, "id_type")
dropdown.find_element(By.XPATH, "//option[. = 'Password']").click()
self.driver.find_element(By.ID, "id_placeholder").send_keys("Password")
self.driver.find_element(By.ID, "id_order").send_keys("1")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Password Repeat Prompt
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_field_key").send_keys("password_repeat")
self.driver.find_element(By.ID, "id_label").send_keys("Password (repeat)")
dropdown = self.driver.find_element(By.ID, "id_type")
dropdown.find_element(By.XPATH, "//option[. = 'Password']").click()
self.driver.find_element(By.ID, "id_placeholder").send_keys("Password (repeat)")
self.driver.find_element(By.ID, "id_order").send_keys("2")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Name Prompt
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_field_key").send_keys("name")
self.driver.find_element(By.ID, "id_label").send_keys("Name")
dropdown = self.driver.find_element(By.ID, "id_type")
dropdown.find_element(By.XPATH, "//option[. = 'Text']").click()
self.driver.find_element(By.ID, "id_placeholder").send_keys("Name")
self.driver.find_element(By.ID, "id_order").send_keys("0")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Email Prompt
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_field_key").send_keys("email")
self.driver.find_element(By.ID, "id_label").send_keys("Email")
dropdown = self.driver.find_element(By.ID, "id_type")
dropdown.find_element(By.XPATH, "//option[. = 'Email']").click()
self.driver.find_element(By.ID, "id_placeholder").send_keys("Email")
self.driver.find_element(By.ID, "id_order").send_keys("1")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
self.driver.find_element(By.LINK_TEXT, "Stages").click()
# Create first enroll prompt stage
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click()
self.driver.find_element(
By.CSS_SELECTOR, "li:nth-child(9) > .pf-c-dropdown__menu-item > small"
).click()
self.driver.find_element(By.ID, "id_name").send_keys(
"enroll-prompt-stage-first"
)
dropdown = self.driver.find_element(By.ID, "id_fields")
dropdown.find_element(
By.XPATH, "//option[. = \"Prompt 'username' type=text\"]"
).click()
dropdown.find_element(
By.XPATH, "//option[. = \"Prompt 'password' type=password\"]"
).click()
dropdown.find_element(
By.XPATH, "//option[. = \"Prompt 'password_repeat' type=password\"]"
).click()
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create second enroll prompt stage
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click()
self.driver.find_element(
By.CSS_SELECTOR, "li:nth-child(9) > .pf-c-dropdown__menu-item"
).click()
self.driver.find_element(By.ID, "id_name").send_keys(
"enroll-prompt-stage-second"
)
dropdown = self.driver.find_element(By.ID, "id_fields")
dropdown.find_element(
By.XPATH, "//option[. = \"Prompt 'name' type=text\"]"
).click()
dropdown.find_element(
By.XPATH, "//option[. = \"Prompt 'email' type=email\"]"
).click()
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create user write stage
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click()
self.driver.find_element(
By.CSS_SELECTOR, "li:nth-child(13) > .pf-c-dropdown__menu-item"
).click()
self.driver.find_element(By.ID, "id_name").send_keys("enroll-user-write")
self.driver.find_element(By.ID, "id_name").send_keys(Keys.ENTER)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click()
# Create user login stage
self.driver.find_element(
By.CSS_SELECTOR, "li:nth-child(11) > .pf-c-dropdown__menu-item"
).click()
self.driver.find_element(By.ID, "id_name").send_keys("enroll-user-login")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
self.driver.find_element(
By.CSS_SELECTOR,
".pf-c-nav__item:nth-child(7) .pf-c-nav__item:nth-child(1) > .pf-c-nav__link",
).click()
# Create password policy
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click()
self.driver.find_element(
By.CSS_SELECTOR, "li:nth-child(2) > .pf-c-dropdown__menu-item > small"
).click()
self.driver.find_element(By.ID, "id_name").send_keys(
"policy-enrollment-password-equals"
)
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, ".CodeMirror-scroll"))
)
self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror-scroll").click()
self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror textarea").send_keys(
"return request.context['password'] == request.context['password_repeat']"
)
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create password policy binding
self.driver.find_element(
By.CSS_SELECTOR,
".pf-c-nav__item:nth-child(7) .pf-c-nav__item:nth-child(2) > .pf-c-nav__link",
).click()
self.driver.find_element(By.LINK_TEXT, "Create").click()
dropdown = self.driver.find_element(By.ID, "id_policy")
dropdown.find_element(
By.XPATH, '//option[. = "Policy policy-enrollment-password-equals"]'
).click()
self.driver.find_element(By.ID, "id_target").click()
dropdown = self.driver.find_element(By.ID, "id_target")
dropdown.find_element(
By.XPATH, '//option[. = "Prompt Stage enroll-prompt-stage-first"]'
).click()
self.driver.find_element(By.ID, "id_order").send_keys("0")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Flow
self.driver.find_element(
By.CSS_SELECTOR,
".pf-c-nav__item:nth-child(6) .pf-c-nav__item:nth-child(1) > .pf-c-nav__link",
).click()
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_name").send_keys("Welcome")
self.driver.find_element(By.ID, "id_slug").clear()
self.driver.find_element(By.ID, "id_slug").send_keys("default-enrollment-flow")
dropdown = self.driver.find_element(By.ID, "id_designation")
dropdown.find_element(By.XPATH, '//option[. = "Enrollment"]').click()
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
self.driver.find_element(By.LINK_TEXT, "Stages").click()
# Edit identification stage
self.driver.find_element(
By.CSS_SELECTOR, "tr:nth-child(11) .pf-m-secondary"
).click()
self.driver.find_element(
By.CSS_SELECTOR,
".pf-c-form__group:nth-child(5) .pf-c-form__horizontal-group",
).click()
self.driver.find_element(By.ID, "id_enrollment_flow").click()
dropdown = self.driver.find_element(By.ID, "id_enrollment_flow")
dropdown.find_element(
By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]'
).click()
self.driver.find_element(By.ID, "id_user_fields_add_all_link").click()
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
self.driver.find_element(By.LINK_TEXT, "Bindings").click()
# Create Stage binding for first prompt stage
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_flow").click()
dropdown = self.driver.find_element(By.ID, "id_flow")
dropdown.find_element(
By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]'
).click()
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-form").click()
self.driver.find_element(By.ID, "id_stage").click()
dropdown = self.driver.find_element(By.ID, "id_stage")
dropdown.find_element(
By.XPATH, '//option[. = "Stage enroll-prompt-stage-first"]'
).click()
self.driver.find_element(By.ID, "id_order").click()
self.driver.find_element(By.ID, "id_order").send_keys("0")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Stage binding for second prompt stage
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_flow").click()
dropdown = self.driver.find_element(By.ID, "id_flow")
dropdown.find_element(
By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]'
).click()
self.driver.find_element(By.ID, "id_stage").click()
dropdown = self.driver.find_element(By.ID, "id_stage")
dropdown.find_element(
By.XPATH, '//option[. = "Stage enroll-prompt-stage-second"]'
).click()
self.driver.find_element(By.ID, "id_order").click()
self.driver.find_element(By.ID, "id_order").send_keys("1")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Stage binding for user write stage
self.driver.find_element(By.LINK_TEXT, "Create").click()
self.driver.find_element(By.ID, "id_flow").click()
dropdown = self.driver.find_element(By.ID, "id_flow")
dropdown.find_element(
By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]'
).click()
self.driver.find_element(By.ID, "id_stage").click()
dropdown = self.driver.find_element(By.ID, "id_stage")
dropdown.find_element(
By.XPATH, '//option[. = "Stage enroll-user-write"]'
).click()
self.driver.find_element(By.ID, "id_order").click()
self.driver.find_element(By.ID, "id_order").send_keys("2")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
# Create Stage binding for user login stage
self.driver.find_element(By.LINK_TEXT, "Create").click()
dropdown = self.driver.find_element(By.ID, "id_flow")
dropdown.find_element(
By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]'
).click()
dropdown = self.driver.find_element(By.ID, "id_stage")
dropdown.find_element(
By.XPATH, '//option[. = "Stage enroll-user-login"]'
).click()
self.driver.find_element(By.ID, "id_order").send_keys("3")
self.driver.find_element(
By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary"
).click()
self.driver.find_element(By.CSS_SELECTOR, "[aria-label=logout]").click()
def test_enroll_2_step(self):
"""Test 2-step enroll flow"""
self.driver.get(self.live_server_url)
self.setup_test_enroll_2_step()
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]"))
)
self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click()
self.wait.until(ec.presence_of_element_located((By.ID, "id_username")))
self.driver.find_element(By.ID, "id_username").send_keys("foo")
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.driver.find_element(By.ID, "id_name").send_keys("some name")
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo")))
self.driver.find_element(By.LINK_TEXT, "foo").click()
self.wait_for_url(self.url("passbook_core:user-settings"))
self.assertEqual(
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
"foo",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"some name",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"foo@bar.baz",
)
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend")
def test_enroll_email(self):
"""Test enroll with Email verification"""
# First stage fields
username_prompt = Prompt.objects.create(
field_key="username", label="Username", order=0, type=FieldTypes.TEXT
)
password = Prompt.objects.create(
field_key="password", label="Password", order=1, type=FieldTypes.PASSWORD
)
password_repeat = Prompt.objects.create(
field_key="password_repeat",
label="Password (repeat)",
order=2,
type=FieldTypes.PASSWORD,
)
# Second stage fields
name_field = Prompt.objects.create(
field_key="name", label="Name", order=0, type=FieldTypes.TEXT
)
email = Prompt.objects.create(
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
)
# Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email])
second_stage.save()
email_stage = EmailStage.objects.create(
name="enroll-email",
host="localhost",
port=1025,
template=EmailTemplates.ACCOUNT_CONFIRM,
)
user_write = UserWriteStage.objects.create(name="enroll-user-write")
user_login = UserLoginStage.objects.create(name="enroll-user-login")
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name="policy-enrollment-password-equals",
expression="return request.context['password'] == request.context['password_repeat']",
)
PolicyBinding.objects.create(
target=first_stage, policy=password_policy, order=0
)
flow = Flow.objects.create(
name="default-enrollment-flow",
slug="default-enrollment-flow",
designation=FlowDesignation.ENROLLMENT,
)
# Attach enrollment flow to identification stage
ident_stage: IdentificationStage = IdentificationStage.objects.first()
ident_stage.enrollment_flow = flow
ident_stage.save()
FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0)
FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1)
FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2)
FlowStageBinding.objects.create(flow=flow, stage=email_stage, order=3)
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=4)
self.driver.get(self.live_server_url)
self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click()
self.driver.find_element(By.ID, "id_username").send_keys("foo")
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.driver.find_element(By.ID, "id_name").send_keys("some name")
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
sleep(3)
# Open Mailhog
self.driver.get("http://localhost:8025")
# Click on first message
self.driver.find_element(By.CLASS_NAME, "msglist-message").click()
sleep(3)
self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane"))
self.driver.find_element(By.ID, "confirm").click()
self.driver.close()
self.driver.switch_to.window(self.driver.window_handles[0])
# We're now logged in
sleep(3)
self.wait.until(
ec.presence_of_element_located(
(By.XPATH, "//a[contains(@href, '/-/user/')]")
)
)
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
self.assertEqual(
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
"foo",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"some name",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"foo@bar.baz",
)

260
e2e/test_flows_enroll.py Normal file
View file

@ -0,0 +1,260 @@
"""Test Enroll flow"""
from time import sleep
from django.test import override_settings
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from e2e.utils import USER, SeleniumTestCase
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.models import PolicyBinding
from passbook.stages.email.models import EmailStage, EmailTemplates
from passbook.stages.identification.models import IdentificationStage
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
from passbook.stages.user_login.models import UserLoginStage
from passbook.stages.user_write.models import UserWriteStage
class TestFlowsEnroll(SeleniumTestCase):
"""Test Enroll flow"""
def setUp(self):
super().setUp()
self.container = self.setup_client()
def setup_client(self) -> Container:
"""Setup test IdP container"""
client: DockerClient = from_env()
container = client.containers.run(
image="mailhog/mailhog",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
test=["CMD", "wget", "-s", "http://localhost:8025"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
def test_enroll_2_step(self):
"""Test 2-step enroll flow"""
# First stage fields
username_prompt = Prompt.objects.create(
field_key="username", label="Username", order=0, type=FieldTypes.TEXT
)
password = Prompt.objects.create(
field_key="password", label="Password", order=1, type=FieldTypes.PASSWORD
)
password_repeat = Prompt.objects.create(
field_key="password_repeat",
label="Password (repeat)",
order=2,
type=FieldTypes.PASSWORD,
)
# Second stage fields
name_field = Prompt.objects.create(
field_key="name", label="Name", order=0, type=FieldTypes.TEXT
)
email = Prompt.objects.create(
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
)
# Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email])
second_stage.save()
user_write = UserWriteStage.objects.create(name="enroll-user-write")
user_login = UserLoginStage.objects.create(name="enroll-user-login")
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name="policy-enrollment-password-equals",
expression="return request.context['password'] == request.context['password_repeat']",
)
PolicyBinding.objects.create(
target=first_stage, policy=password_policy, order=0
)
flow = Flow.objects.create(
name="default-enrollment-flow",
slug="default-enrollment-flow",
designation=FlowDesignation.ENROLLMENT,
)
# Attach enrollment flow to identification stage
ident_stage: IdentificationStage = IdentificationStage.objects.first()
ident_stage.enrollment_flow = flow
ident_stage.save()
FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0)
FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1)
FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2)
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=3)
self.driver.get(self.live_server_url)
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]"))
)
self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click()
self.wait.until(ec.presence_of_element_located((By.ID, "id_username")))
self.driver.find_element(By.ID, "id_username").send_keys("foo")
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.driver.find_element(By.ID, "id_name").send_keys("some name")
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo")))
self.driver.find_element(By.LINK_TEXT, "foo").click()
self.wait_for_url(self.url("passbook_core:user-settings"))
self.assertEqual(
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
"foo",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"some name",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"foo@bar.baz",
)
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend")
def test_enroll_email(self):
"""Test enroll with Email verification"""
# First stage fields
username_prompt = Prompt.objects.create(
field_key="username", label="Username", order=0, type=FieldTypes.TEXT
)
password = Prompt.objects.create(
field_key="password", label="Password", order=1, type=FieldTypes.PASSWORD
)
password_repeat = Prompt.objects.create(
field_key="password_repeat",
label="Password (repeat)",
order=2,
type=FieldTypes.PASSWORD,
)
# Second stage fields
name_field = Prompt.objects.create(
field_key="name", label="Name", order=0, type=FieldTypes.TEXT
)
email = Prompt.objects.create(
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
)
# Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email])
second_stage.save()
email_stage = EmailStage.objects.create(
name="enroll-email",
host="localhost",
port=1025,
template=EmailTemplates.ACCOUNT_CONFIRM,
)
user_write = UserWriteStage.objects.create(name="enroll-user-write")
user_login = UserLoginStage.objects.create(name="enroll-user-login")
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name="policy-enrollment-password-equals",
expression="return request.context['password'] == request.context['password_repeat']",
)
PolicyBinding.objects.create(
target=first_stage, policy=password_policy, order=0
)
flow = Flow.objects.create(
name="default-enrollment-flow",
slug="default-enrollment-flow",
designation=FlowDesignation.ENROLLMENT,
)
# Attach enrollment flow to identification stage
ident_stage: IdentificationStage = IdentificationStage.objects.first()
ident_stage.enrollment_flow = flow
ident_stage.save()
FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0)
FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1)
FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2)
FlowStageBinding.objects.create(flow=flow, stage=email_stage, order=3)
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=4)
self.driver.get(self.live_server_url)
self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click()
self.driver.find_element(By.ID, "id_username").send_keys("foo")
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
self.driver.find_element(By.ID, "id_name").send_keys("some name")
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
sleep(3)
# Open Mailhog
self.driver.get("http://localhost:8025")
# Click on first message
self.driver.find_element(By.CLASS_NAME, "msglist-message").click()
sleep(3)
self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane"))
self.driver.find_element(By.ID, "confirm").click()
self.driver.close()
self.driver.switch_to.window(self.driver.window_handles[0])
# We're now logged in
sleep(3)
self.wait.until(
ec.presence_of_element_located(
(By.XPATH, "//a[contains(@href, '/-/user/')]")
)
)
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
self.assertEqual(
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text,
"foo",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"some name",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"foo@bar.baz",
)

View file

@ -5,7 +5,7 @@ from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase from e2e.utils import USER, SeleniumTestCase
class TestLogin(SeleniumTestCase): class TestFlowsLogin(SeleniumTestCase):
"""test default login flow""" """test default login flow"""
def test_login(self): def test_login(self):

View file

@ -0,0 +1,41 @@
"""test stage setup flows (password change)"""
import string
from random import SystemRandom
from time import sleep
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import User
class TestFlowsStageSetup(SeleniumTestCase):
"""test stage setup flows"""
def test_password_change(self):
"""test password change flow"""
new_password = "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
)
self.driver.get(
f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F"
)
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.driver.find_element(By.CSS_SELECTOR, ".pf-c-page__header").click()
self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click()
self.driver.find_element(By.LINK_TEXT, "Change password").click()
self.driver.find_element(By.ID, "id_password").send_keys(new_password)
self.driver.find_element(By.ID, "id_password_repeat").click()
self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
sleep(2)
# Because USER() is cached, we need to get the user manually here
user = User.objects.get(username=USER().username)
self.assertTrue(user.check_password(new_password))

View file

@ -88,6 +88,7 @@ class TestProviderSAML(SeleniumTestCase):
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) 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(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.wait_for_url("http://localhost:9009/")
self.assertEqual( self.assertEqual(
self.driver.find_element(By.XPATH, "/html/body/pre").text, self.driver.find_element(By.XPATH, "/html/body/pre").text,
f"Hello, {USER().name}!", f"Hello, {USER().name}!",
@ -128,6 +129,7 @@ class TestProviderSAML(SeleniumTestCase):
).text, ).text,
) )
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
self.wait_for_url("http://localhost:9009/")
self.assertEqual( self.assertEqual(
self.driver.find_element(By.XPATH, "/html/body/pre").text, self.driver.find_element(By.XPATH, "/html/body/pre").text,
f"Hello, {USER().name}!", f"Hello, {USER().name}!",
@ -166,6 +168,7 @@ class TestProviderSAML(SeleniumTestCase):
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) 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(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.wait_for_url("http://localhost:9009/")
self.assertEqual( self.assertEqual(
self.driver.find_element(By.XPATH, "/html/body/pre").text, self.driver.find_element(By.XPATH, "/html/body/pre").text,
f"Hello, {USER().name}!", f"Hello, {USER().name}!",

View file

@ -27,7 +27,7 @@
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid"> <table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead> <thead>
<tr role="row"> <tr role="row">
<th role="columnheader" scope="col">{% trans 'Name' %}</th> <th role="columnheader" scope="col">{% trans 'Identifier' %}</th>
<th role="columnheader" scope="col">{% trans 'Designation' %}</th> <th role="columnheader" scope="col">{% trans 'Designation' %}</th>
<th role="columnheader" scope="col">{% trans 'Stages' %}</th> <th role="columnheader" scope="col">{% trans 'Stages' %}</th>
<th role="columnheader" scope="col">{% trans 'Policies' %}</th> <th role="columnheader" scope="col">{% trans 'Policies' %}</th>
@ -39,8 +39,8 @@
<tr role="row"> <tr role="row">
<th role="columnheader"> <th role="columnheader">
<div> <div>
<div>{{ flow.name }}</div> <div>{{ flow.slug }}</div>
<small>{{ flow.slug }}</small> <small>{{ flow.name }}</small>
</div> </div>
</th> </th>
<td role="cell"> <td role="cell">

View file

@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.debug import CLEANSED_SUBSTITUTE, HIDDEN_SETTINGS
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from structlog import get_logger from structlog import get_logger
@ -20,6 +21,22 @@ from passbook.lib.utils.http import get_client_ip
LOGGER = get_logger() LOGGER = get_logger()
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
"""Cleanse a dictionary, recursively"""
final_dict = {}
for key, value in source.items():
try:
if HIDDEN_SETTINGS.search(key):
final_dict[key] = CLEANSED_SUBSTITUTE
else:
final_dict[key] = value
except TypeError:
final_dict[key] = value
if isinstance(value, dict):
final_dict[key] = cleanse_dict(value)
return final_dict
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
"""clean source of all Models that would interfere with the JSONField. """clean source of all Models that would interfere with the JSONField.
Models are replaced with a dictionary of { Models are replaced with a dictionary of {
@ -27,15 +44,16 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
name: str, name: str,
pk: Any pk: Any
}""" }"""
final_dict = {}
for key, value in source.items(): for key, value in source.items():
if isinstance(value, dict): if isinstance(value, dict):
source[key] = sanitize_dict(value) final_dict[key] = sanitize_dict(value)
elif isinstance(value, models.Model): elif isinstance(value, models.Model):
model_content_type = ContentType.objects.get_for_model(value) model_content_type = ContentType.objects.get_for_model(value)
name = str(value) name = str(value)
if hasattr(value, "name"): if hasattr(value, "name"):
name = value.name name = value.name
source[key] = sanitize_dict( final_dict[key] = sanitize_dict(
{ {
"app": model_content_type.app_label, "app": model_content_type.app_label,
"model_name": model_content_type.model, "model_name": model_content_type.model,
@ -44,8 +62,10 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
} }
) )
elif isinstance(value, UUID): elif isinstance(value, UUID):
source[key] = value.hex final_dict[key] = value.hex
return source else:
final_dict[key] = value
return final_dict
class EventAction(Enum): class EventAction(Enum):
@ -104,7 +124,7 @@ class Event(models.Model):
) )
if not app: if not app:
app = getmodule(stack()[_inspect_offset][0]).__name__ app = getmodule(stack()[_inspect_offset][0]).__name__
cleaned_kwargs = sanitize_dict(kwargs) cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
event = Event(action=action.value, app=app, context=cleaned_kwargs) event = Event(action=action.value, app=app, context=cleaned_kwargs)
return event return event

View file

@ -25,8 +25,7 @@
<ul class="pf-c-nav__list"> <ul class="pf-c-nav__list">
{% for stage in user_stages_loc %} {% for stage in user_stages_loc %}
<li class="pf-c-nav__item"> <li class="pf-c-nav__item">
<a href="{% url stage.view_name %}" class="pf-c-nav__link {% is_active stage.view_name %}"> <a href="{{ stage.url }}" class="pf-c-nav__link {% is_active stage.view_name %}">
<i class="{{ stage.icon }}"></i>
{{ stage.name }} {{ stage.name }}
</a> </a>
</li> </li>
@ -43,7 +42,6 @@
<li class="pf-c-nav__item"> <li class="pf-c-nav__item">
<a href="{{ source.view_name }}" <a href="{{ source.view_name }}"
class="pf-c-nav__link {% if user_settings.view_name == request.get_full_path %} pf-m-current {% endif %}"> class="pf-c-nav__link {% if user_settings.view_name == request.get_full_path %} pf-m-current {% endif %}">
<i class="{{ source.icon }}"></i>
{{ source.name }} {{ source.name }}
</a> </a>
</li> </li>

View file

@ -19,7 +19,7 @@ def user_stages(context: RequestContext) -> List[UIUserSettings]:
_all_stages: Iterable[Stage] = Stage.__subclasses__() _all_stages: Iterable[Stage] = Stage.__subclasses__()
matching_stages: List[UIUserSettings] = [] matching_stages: List[UIUserSettings] = []
for stage in _all_stages: for stage in _all_stages:
user_settings = stage.ui_user_settings(context) user_settings = stage.ui_user_settings
if not user_settings: if not user_settings:
continue continue
matching_stages.append(user_settings) matching_stages.append(user_settings)
@ -38,9 +38,7 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]:
user_settings = source.ui_user_settings user_settings = source.ui_user_settings
if not user_settings: if not user_settings:
continue continue
policy_engine = PolicyEngine( policy_engine = PolicyEngine(source, user, context.get("request"))
source.policies.all(), user, context.get("request")
)
policy_engine.build() policy_engine.build()
if policy_engine.passing: if policy_engine.passing:
matching_sources.append(user_settings) matching_sources.append(user_settings)

View file

@ -8,8 +8,7 @@ class UIUserSettings:
"""Dataclass for Stage and Source's user_settings""" """Dataclass for Stage and Source's user_settings"""
name: str name: str
icon: str url: str
view_name: str
@dataclass @dataclass

View file

@ -20,42 +20,38 @@ def create_default_authentication_flow(
) )
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
if ( identification_stage, _ = IdentificationStage.objects.using(
Flow.objects.using(db_alias) db_alias
.filter(designation=FlowDesignation.AUTHENTICATION) ).update_or_create(
.exists() name="default-authentication-identification",
): defaults={
# Only create default flow when none exist "user_fields": [UserFields.E_MAIL, UserFields.USERNAME],
return "template": Templates.DEFAULT_LOGIN,
},
)
if not IdentificationStage.objects.using(db_alias).exists(): password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create(
IdentificationStage.objects.using(db_alias).create( name="default-authentication-password",
name="identification", defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]},
user_fields=[UserFields.E_MAIL, UserFields.USERNAME], )
template=Templates.DEFAULT_LOGIN,
)
if not PasswordStage.objects.using(db_alias).exists(): login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create(
PasswordStage.objects.using(db_alias).create( name="default-authentication-login"
name="password", backends=["django.contrib.auth.backends.ModelBackend"], )
)
if not UserLoginStage.objects.using(db_alias).exists(): flow, _ = Flow.objects.using(db_alias).update_or_create(
UserLoginStage.objects.using(db_alias).create(name="authentication")
flow = Flow.objects.using(db_alias).create(
name="Welcome to passbook!",
slug="default-authentication-flow", slug="default-authentication-flow",
designation=FlowDesignation.AUTHENTICATION, designation=FlowDesignation.AUTHENTICATION,
defaults={"name": "Welcome to passbook!",},
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=IdentificationStage.objects.using(db_alias).first(), order=0, flow=flow, stage=identification_stage, defaults={"order": 0,},
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=PasswordStage.objects.using(db_alias).first(), order=1, flow=flow, stage=password_stage, defaults={"order": 1,},
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=UserLoginStage.objects.using(db_alias).first(), order=2, flow=flow, stage=login_stage, defaults={"order": 2,},
) )
@ -67,24 +63,19 @@ def create_default_invalidation_flow(
UserLogoutStage = apps.get_model("passbook_stages_user_logout", "UserLogoutStage") UserLogoutStage = apps.get_model("passbook_stages_user_logout", "UserLogoutStage")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
if ( UserLogoutStage.objects.using(db_alias).update_or_create(
Flow.objects.using(db_alias) name="default-invalidation-logout"
.filter(designation=FlowDesignation.INVALIDATION) )
.exists()
):
# Only create default flow when none exist
return
if not UserLogoutStage.objects.using(db_alias).exists(): flow, _ = Flow.objects.using(db_alias).update_or_create(
UserLogoutStage.objects.using(db_alias).create(name="logout")
flow = Flow.objects.using(db_alias).create(
name="default-invalidation-flow",
slug="default-invalidation-flow", slug="default-invalidation-flow",
designation=FlowDesignation.INVALIDATION, designation=FlowDesignation.INVALIDATION,
defaults={"name": "Logout",},
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=UserLogoutStage.objects.using(db_alias).first(), order=0, flow=flow,
stage=UserLogoutStage.objects.using(db_alias).first(),
defaults={"order": 0,},
) )

View file

@ -34,60 +34,63 @@ def create_default_source_enrollment_flow(
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
# Create a policy that only allows this flow when doing an SSO Request # Create a policy that only allows this flow when doing an SSO Request
flow_policy = ExpressionPolicy.objects.using(db_alias).create( flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-source-enrollment-if-sso", expression=FLOW_POLICY_EXPRESSION name="default-source-enrollment-if-sso",
defaults={"expression": FLOW_POLICY_EXPRESSION},
) )
# This creates a Flow used by sources to enroll users # This creates a Flow used by sources to enroll users
# It makes sure that a username is set, and if not, prompts the user for a Username # It makes sure that a username is set, and if not, prompts the user for a Username
flow = Flow.objects.using(db_alias).create( flow, _ = Flow.objects.using(db_alias).update_or_create(
name="default-source-enrollment",
slug="default-source-enrollment", slug="default-source-enrollment",
designation=FlowDesignation.ENROLLMENT, designation=FlowDesignation.ENROLLMENT,
defaults={"name": "Welcome to passbook!",},
) )
PolicyBinding.objects.using(db_alias).create( PolicyBinding.objects.using(db_alias).update_or_create(
policy=flow_policy, target=flow, order=0 policy=flow_policy, target=flow, defaults={"order": 0}
) )
# PromptStage to ask user for their username # PromptStage to ask user for their username
prompt_stage = PromptStage.objects.using(db_alias).create( prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
name="default-source-enrollment-username-prompt", name="default-source-enrollment-username-prompt",
) )
prompt_stage.fields.add( prompt, _ = Prompt.objects.using(db_alias).update_or_create(
Prompt.objects.using(db_alias).create( field_key="username",
field_key="username", defaults={
label="Username", "label": "Username",
type=FieldTypes.TEXT, "type": FieldTypes.TEXT,
required=True, "required": True,
placeholder="Username", "placeholder": "Username",
) },
) )
prompt_stage.fields.add(prompt)
# Policy to only trigger prompt when no username is given # Policy to only trigger prompt when no username is given
prompt_policy = ExpressionPolicy.objects.using(db_alias).create( prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-source-enrollment-if-username", name="default-source-enrollment-if-username",
expression=PROMPT_POLICY_EXPRESSION, defaults={"expression": PROMPT_POLICY_EXPRESSION},
) )
# UserWrite stage to create the user, and login stage to log user in # UserWrite stage to create the user, and login stage to log user in
user_write = UserWriteStage.objects.using(db_alias).create( user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
name="default-source-enrollment-write" name="default-source-enrollment-write"
) )
user_login = UserLoginStage.objects.using(db_alias).create( user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create(
name="default-source-enrollment-login" name="default-source-enrollment-login"
) )
binding = FlowStageBinding.objects.using(db_alias).create( binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=prompt_stage, order=0 flow=flow, stage=prompt_stage, defaults={"order": 0}
) )
PolicyBinding.objects.using(db_alias).create( PolicyBinding.objects.using(db_alias).update_or_create(
policy=prompt_policy, target=binding, order=0 policy=prompt_policy, target=binding, defaults={"order": 0}
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=user_write, order=1 flow=flow, stage=user_write, defaults={"order": 1}
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=user_login, order=2 flow=flow, stage=user_login, defaults={"order": 2}
) )
@ -107,25 +110,26 @@ def create_default_source_authentication_flow(
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
# Create a policy that only allows this flow when doing an SSO Request # Create a policy that only allows this flow when doing an SSO Request
flow_policy = ExpressionPolicy.objects.using(db_alias).create( flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION name="default-source-authentication-if-sso",
defaults={"expression": FLOW_POLICY_EXPRESSION,},
) )
# This creates a Flow used by sources to authenticate users # This creates a Flow used by sources to authenticate users
flow = Flow.objects.using(db_alias).create( flow, _ = Flow.objects.using(db_alias).update_or_create(
name="default-source-authentication",
slug="default-source-authentication", slug="default-source-authentication",
designation=FlowDesignation.AUTHENTICATION, designation=FlowDesignation.AUTHENTICATION,
defaults={"name": "Welcome to passbook!",},
) )
PolicyBinding.objects.using(db_alias).create( PolicyBinding.objects.using(db_alias).update_or_create(
policy=flow_policy, target=flow, order=0 policy=flow_policy, target=flow, defaults={"order": 0}
) )
user_login = UserLoginStage.objects.using(db_alias).create( user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create(
name="default-source-authentication-login" name="default-source-authentication-login"
) )
FlowStageBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=user_login, order=0 flow=flow, stage=user_login, defaults={"order": 0}
) )

View file

@ -7,7 +7,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from passbook.flows.models import FlowDesignation from passbook.flows.models import FlowDesignation
def create_default_provider_authz_flow( def create_default_provider_authorization_flow(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor apps: Apps, schema_editor: BaseDatabaseSchemaEditor
): ):
Flow = apps.get_model("passbook_flows", "Flow") Flow = apps.get_model("passbook_flows", "Flow")
@ -18,22 +18,24 @@ def create_default_provider_authz_flow(
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
# Empty flow for providers where consent is implicitly given # Empty flow for providers where consent is implicitly given
Flow.objects.using(db_alias).create( Flow.objects.using(db_alias).update_or_create(
name="Authorize Application",
slug="default-provider-authorization-implicit-consent", slug="default-provider-authorization-implicit-consent",
designation=FlowDesignation.AUTHORIZATION, designation=FlowDesignation.AUTHORIZATION,
defaults={"name": "Authorize Application"},
) )
# Flow with consent form to obtain explicit user consent # Flow with consent form to obtain explicit user consent
flow = Flow.objects.using(db_alias).create( flow, _ = Flow.objects.using(db_alias).update_or_create(
name="Authorize Application",
slug="default-provider-authorization-explicit-consent", slug="default-provider-authorization-explicit-consent",
designation=FlowDesignation.AUTHORIZATION, designation=FlowDesignation.AUTHORIZATION,
defaults={"name": "Authorize Application"},
) )
stage = ConsentStage.objects.using(db_alias).create( stage, _ = ConsentStage.objects.using(db_alias).update_or_create(
name="default-provider-authorization-consent" name="default-provider-authorization-consent"
) )
FlowStageBinding.objects.using(db_alias).create(flow=flow, stage=stage, order=0) FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=stage, defaults={"order": 0}
)
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -43,4 +45,4 @@ class Migration(migrations.Migration):
("passbook_stages_consent", "0001_initial"), ("passbook_stages_consent", "0001_initial"),
] ]
operations = [migrations.RunPython(create_default_provider_authz_flow)] operations = [migrations.RunPython(create_default_provider_authorization_flow)]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.7 on 2020-06-29 08:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0005_provider_flows"),
]
operations = [
migrations.AlterField(
model_name="flow",
name="designation",
field=models.CharField(
choices=[
("authentication", "Authentication"),
("authorization", "Authorization"),
("invalidation", "Invalidation"),
("enrollment", "Enrollment"),
("unenrollment", "Unrenollment"),
("recovery", "Recovery"),
("stage_setup", "Stage Setup"),
],
max_length=100,
),
),
]

View file

@ -7,7 +7,6 @@ from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from structlog import get_logger from structlog import get_logger
from django.template.context import RequestContext
from passbook.core.types import UIUserSettings from passbook.core.types import UIUserSettings
from passbook.lib.utils.reflection import class_to_path from passbook.lib.utils.reflection import class_to_path
@ -33,7 +32,7 @@ class FlowDesignation(models.TextChoices):
ENROLLMENT = "enrollment" ENROLLMENT = "enrollment"
UNRENOLLMENT = "unenrollment" UNRENOLLMENT = "unenrollment"
RECOVERY = "recovery" RECOVERY = "recovery"
USER_SETTINGS = "user_settings" STAGE_SETUP = "stage_setup"
class Stage(models.Model): class Stage(models.Model):
@ -48,8 +47,8 @@ class Stage(models.Model):
type = "" type = ""
form = "" form = ""
@staticmethod @property
def ui_user_settings(context: RequestContext) -> Optional[UIUserSettings]: def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UIUserSettings.""" user settings are available, or an instanace of UIUserSettings."""
return None return None

View file

@ -13,6 +13,7 @@ def delete_cache_prefix(prefix: str) -> int:
cache.delete_many(keys) cache.delete_many(keys)
return len(keys) return len(keys)
@receiver(post_save) @receiver(post_save)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def invalidate_flow_cache(sender, instance, **_): def invalidate_flow_cache(sender, instance, **_):
@ -25,7 +26,9 @@ def invalidate_flow_cache(sender, instance, **_):
LOGGER.debug("Invalidating Flow cache", flow=instance, len=total) LOGGER.debug("Invalidating Flow cache", flow=instance, len=total)
if isinstance(instance, FlowStageBinding): if isinstance(instance, FlowStageBinding):
total = delete_cache_prefix(f"{cache_key(instance.flow)}*") total = delete_cache_prefix(f"{cache_key(instance.flow)}*")
LOGGER.debug("Invalidating Flow cache from FlowStageBinding", binding=instance, len=total) LOGGER.debug(
"Invalidating Flow cache from FlowStageBinding", binding=instance, len=total
)
if isinstance(instance, Stage): if isinstance(instance, Stage):
total = 0 total = 0
for binding in FlowStageBinding.objects.filter(stage=instance): for binding in FlowStageBinding.objects.filter(stage=instance):

View file

@ -15,6 +15,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import cleanse_dict
from passbook.core.views.utils import PermissionDeniedView from passbook.core.views.utils import PermissionDeniedView
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, Stage from passbook.flows.models import Flow, FlowDesignation, Stage
@ -161,7 +162,7 @@ class FlowExecutorView(View):
LOGGER.debug( LOGGER.debug(
"f(exec): User passed all stages", "f(exec): User passed all stages",
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
context=self.plan.context, context=cleanse_dict(self.plan.context),
) )
return self._flow_done() return self._flow_done()

View file

@ -1,4 +1,5 @@
"""OAuth Client models""" """OAuth Client models"""
from typing import Optional
from django.db import models from django.db import models
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@ -61,16 +62,10 @@ class OAuthSource(Source):
return f"Callback URL: <pre>{url}</pre>" return f"Callback URL: <pre>{url}</pre>"
@property @property
def ui_user_settings(self) -> UIUserSettings: def ui_user_settings(self) -> Optional[UIUserSettings]:
icon_type = self.provider_type
if icon_type == "azure ad":
icon_type = "windows"
icon_class = f"fab fa-{icon_type}"
view_name = "passbook_sources_oauth:oauth-client-user" view_name = "passbook_sources_oauth:oauth-client-user"
return UIUserSettings( return UIUserSettings(
name=self.name, name=self.name, url=reverse(view_name, kwargs={"source_slug": self.slug}),
icon=icon_class,
view_name=reverse((view_name), kwargs={"source_slug": self.slug}),
) )
def __str__(self) -> str: def __str__(self) -> str:

View file

@ -153,7 +153,7 @@ class Processor:
self, request: HttpRequest, flow: Flow, **kwargs self, request: HttpRequest, flow: Flow, **kwargs
) -> HttpResponse: ) -> HttpResponse:
kwargs[PLAN_CONTEXT_SSO] = True kwargs[PLAN_CONTEXT_SSO] = True
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs,) request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs)
return redirect_with_qs( return redirect_with_qs(
"passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug, "passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug,
) )

View file

@ -5,6 +5,7 @@ from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from structlog import get_logger from structlog import get_logger
from passbook.flows.models import Flow, FlowDesignation
from passbook.lib.utils.ui import human_list from passbook.lib.utils.ui import human_list
from passbook.stages.identification.models import IdentificationStage, UserFields from passbook.stages.identification.models import IdentificationStage, UserFields
@ -14,6 +15,15 @@ LOGGER = get_logger()
class IdentificationStageForm(forms.ModelForm): class IdentificationStageForm(forms.ModelForm):
"""Form to create/edit IdentificationStage instances""" """Form to create/edit IdentificationStage instances"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["enrollment_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.ENROLLMENT
)
self.fields["recovery_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.RECOVERY
)
class Meta: class Meta:
model = IdentificationStage model = IdentificationStage

View file

@ -8,3 +8,4 @@ class PassbookStagePasswordConfig(AppConfig):
name = "passbook.stages.password" name = "passbook.stages.password"
label = "passbook_stages_password" label = "passbook_stages_password"
verbose_name = "passbook Stages.Password" verbose_name = "passbook Stages.Password"
mountpoint = "-/user/stage/password/"

View file

@ -3,6 +3,7 @@ from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Flow, FlowDesignation
from passbook.stages.password.models import PasswordStage from passbook.stages.password.models import PasswordStage
@ -40,14 +41,19 @@ class PasswordForm(forms.Form):
class PasswordStageForm(forms.ModelForm): class PasswordStageForm(forms.ModelForm):
"""Form to create/edit Password Stages""" """Form to create/edit Password Stages"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["change_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.STAGE_SETUP
)
class Meta: class Meta:
model = PasswordStage model = PasswordStage
fields = ["name", "backends"] fields = ["name", "backends", "change_flow"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"backends": FilteredSelectMultiple( "backends": FilteredSelectMultiple(
_("backends"), False, choices=get_authentication_backends() _("backends"), False, choices=get_authentication_backends()
), ),
"password_policies": FilteredSelectMultiple(_("password policies"), False),
} }

View file

@ -0,0 +1,126 @@
# Generated by Django 3.0.7 on 2020-06-29 08:51
import django.db.models.deletion
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from passbook.flows.models import FlowDesignation
from passbook.stages.prompt.models import FieldTypes
PROMPT_POLICY_EXPRESSION = """# Check that both passwords are equal.
return request.context['password'] == request.context['password_repeat']"""
def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Flow = apps.get_model("passbook_flows", "Flow")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding")
ExpressionPolicy = apps.get_model(
"passbook_policies_expression", "ExpressionPolicy"
)
PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage")
db_alias = schema_editor.connection.alias
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-password-change",
designation=FlowDesignation.STAGE_SETUP,
defaults={"name": "Change Password"},
)
prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
name="default-password-change-prompt",
)
password_prompt, _ = Prompt.objects.using(db_alias).update_or_create(
field_key="password",
defaults={
"label": "Password",
"type": FieldTypes.PASSWORD,
"required": True,
"placeholder": "Password",
"order": 0,
},
)
password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create(
field_key="password_repeat",
defaults={
"label": "Password (repeat)",
"type": FieldTypes.PASSWORD,
"required": True,
"placeholder": "Password (repeat)",
"order": 1,
},
)
prompt_stage.fields.add(password_prompt)
prompt_stage.fields.add(password_rep_prompt)
# Policy to only trigger prompt when no username is given
prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-password-change-password-equal",
defaults={"expression": PROMPT_POLICY_EXPRESSION},
)
PolicyBinding.objects.using(db_alias).update_or_create(
policy=prompt_policy, target=prompt_stage, defaults={"order": 0}
)
user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
name="default-password-change-write"
)
FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=prompt_stage, defaults={"order": 0}
)
FlowStageBinding.objects.using(db_alias).update_or_create(
flow=flow, stage=user_write, defaults={"order": 1}
)
def update_default_stage_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage")
Flow = apps.get_model("passbook_flows", "Flow")
flow = Flow.objects.get(
slug="default-password-change", designation=FlowDesignation.STAGE_SETUP,
)
stages = PasswordStage.objects.filter(name="default-authentication-password")
if not stages.exists():
return
stage = stages.first()
stage.change_flow = flow
stage.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0006_auto_20200629_0857"),
("passbook_policies_expression", "0001_initial"),
("passbook_policies", "0001_initial"),
("passbook_stages_password", "0001_initial"),
("passbook_stages_prompt", "0004_auto_20200618_1735"),
("passbook_stages_user_write", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="passwordstage",
name="change_flow",
field=models.ForeignKey(
blank=True,
help_text="Flow used by an authenticated user to change their password. If empty, user will be unable to change their password.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="passbook_flows.Flow",
),
),
migrations.RunPython(create_default_password_change),
migrations.RunPython(update_default_stage_change),
]

View file

@ -1,9 +1,15 @@
"""password stage models""" """password stage models"""
from typing import Optional
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.shortcuts import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Stage from passbook.core.types import UIUserSettings
from passbook.flows.models import Flow, Stage
from passbook.flows.views import NEXT_ARG_NAME
class PasswordStage(Stage): class PasswordStage(Stage):
@ -14,9 +20,32 @@ class PasswordStage(Stage):
help_text=_("Selection of backends to test the password against."), help_text=_("Selection of backends to test the password against."),
) )
change_flow = models.ForeignKey(
Flow,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text=_(
(
"Flow used by an authenticated user to change their password. "
"If empty, user will be unable to change their password."
)
),
)
type = "passbook.stages.password.stage.PasswordStage" type = "passbook.stages.password.stage.PasswordStage"
form = "passbook.stages.password.forms.PasswordStageForm" form = "passbook.stages.password.forms.PasswordStageForm"
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
if not self.change_flow:
return None
base_url = reverse(
"passbook_stages_password:change", kwargs={"stage_uuid": self.pk}
)
args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")})
return UIUserSettings(name=_("Change password"), url=f"{base_url}?{args}")
def __str__(self): def __str__(self):
return f"Password Stage {self.name}" return f"Password Stage {self.name}"

View file

@ -0,0 +1,8 @@
"""password stage URLs"""
from django.urls import path
from passbook.stages.password.views import ChangeFlowInitView
urlpatterns = [
path("<uuid:stage_uuid>/change/", ChangeFlowInitView.as_view(), name="change")
]

View file

@ -0,0 +1,32 @@
"""password stage views"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.views import View
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.stages.password.models import PasswordStage
class ChangeFlowInitView(LoginRequiredMixin, View):
"""Initiate planner for selected change flow and redirect to flow executor,
or raise Http404 if no change_flow has been set."""
def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse:
"""Initiate planner for selected change flow and redirect to flow executor,
or raise Http404 if no change_flow has been set."""
stage: PasswordStage = get_object_or_404(PasswordStage, pk=stage_uuid)
if not stage.change_flow:
raise Http404
plan = FlowPlanner(stage.change_flow).plan(
request, {PLAN_CONTEXT_PENDING_USER: request.user}
)
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"passbook_flows:flow-executor-shell",
self.request.GET,
flow_slug=stage.change_flow.slug,
)

View file

@ -1,5 +1,7 @@
"""Prompt forms""" """Prompt forms"""
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
@ -16,6 +18,7 @@ class PromptStageForm(forms.ModelForm):
fields = ["name", "fields"] fields = ["name", "fields"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"fields": FilteredSelectMultiple(_("prompts"), False),
} }

View file

@ -1,5 +1,6 @@
"""Write stage logic""" """Write stage logic"""
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -48,6 +49,10 @@ class UserWriteStageView(StageView):
else: else:
user.attributes[key] = value user.attributes[key] = value
user.save() user.save()
# Check if the password has been updated, and update the session auth hash
if any(["password" in x for x in data.keys()]):
update_session_auth_hash(self.request, user)
LOGGER.debug("Updated session hash", user=user)
LOGGER.debug( LOGGER.debug(
"Updated existing user", user=user, flow_slug=self.executor.flow.slug, "Updated existing user", user=user, flow_slug=self.executor.flow.slug,
) )

View file

@ -5177,7 +5177,7 @@ definitions:
- enrollment - enrollment
- unenrollment - unenrollment
- recovery - recovery
- user_settings - stage_setup
stages: stages:
type: array type: array
items: items: